Compare commits
89
Commits
2e8d7c1cfd
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb0644e6c1 | ||
|
|
3558c441b9 | ||
|
|
a6c2d4764e | ||
|
|
d237d00f65 | ||
|
|
6f2ce12c0f | ||
|
|
a9e29b79fd | ||
|
|
e0cd2327eb | ||
|
|
63ae9b22b9 | ||
|
|
cffc828328 | ||
|
|
41d3f8f4eb | ||
|
|
7f6fa3672d | ||
|
|
0d9e866a57 | ||
|
|
0a330b8749 | ||
|
|
88ef4e82a1 | ||
|
|
1a49576752 | ||
|
|
4a9e10e614 | ||
|
|
2a01873c95 | ||
|
|
503b70a690 | ||
|
|
f8b865b0aa | ||
|
|
a1e73d1c9e | ||
|
|
00d0284454 | ||
|
|
ac04e51c8b | ||
|
|
94e9a8ed1c | ||
|
|
c6b7629c77 | ||
|
|
57800634ca | ||
|
|
d37b920c01 | ||
|
|
94cb7eaa33 | ||
|
|
50b716f040 | ||
|
|
5e7bdc0cf2 | ||
|
|
19c0ea25e1 | ||
|
|
95d851f9dd | ||
|
|
b7ca306097 | ||
|
|
6517877bed | ||
|
|
3aef62c1b8 | ||
|
|
d68ef2d08a | ||
|
|
aaf7a83742 | ||
|
|
db305c58a4 | ||
|
|
a2dacfdc97 | ||
|
|
717a203d48 | ||
|
|
584c01fcee | ||
|
|
07f3304c07 | ||
|
|
4a1d60dbef | ||
|
|
4623061a4f | ||
|
|
9a020d748d | ||
|
|
4823cf97f6 | ||
|
|
b1dcecc468 | ||
|
|
cf459807d8 | ||
|
|
5c701d0841 | ||
|
|
53fe8b72bd | ||
|
|
d14f91a606 | ||
|
|
42cf29bc4e | ||
|
|
6f1b13b6dc | ||
|
|
6b8073f998 | ||
|
|
f42e6fe2a4 | ||
|
|
d1f60b5313 | ||
|
|
5813e9fc53 | ||
|
|
cda901d049 | ||
|
|
dcf8b6423a | ||
|
|
73588db1c6 | ||
|
|
a5f2f8d63b | ||
|
|
e37cb4a20d | ||
|
|
0c1c3f7ff9 | ||
|
|
0d9c2fd8ea | ||
|
|
fb04425df7 | ||
|
|
3d3abeb65b | ||
|
|
730407ec3a | ||
|
|
5850c90d84 | ||
|
|
cbe7aced6d | ||
|
|
91a20a89d6 | ||
|
|
4db9c7709d | ||
|
|
92601ae729 | ||
|
|
6cd0b009ef | ||
|
|
3f266a2dcb | ||
|
|
b8f60c5141 | ||
|
|
ead16080b8 | ||
|
|
6ce6f959ac | ||
|
|
e942348b1d | ||
|
|
40284df880 | ||
|
|
20cc0bbd78 | ||
|
|
ad6f9dcf1d | ||
|
|
37bce1e9fa | ||
|
|
af0573c089 | ||
|
|
ea40ae40d8 | ||
|
|
c4367d8eb0 | ||
|
|
df17925d3a | ||
|
|
7cef24336a | ||
|
|
1b16e79d3f | ||
|
|
e0e122ed57 | ||
|
|
24ce75c2e6 |
+7
-1
@@ -30,7 +30,8 @@ SMS_GAMA_BASE_URL=https://sms.igama.ir/api/v1
|
|||||||
SMS_GAMA_USERNAME=
|
SMS_GAMA_USERNAME=
|
||||||
SMS_GAMA_PASSWORD=
|
SMS_GAMA_PASSWORD=
|
||||||
SMS_GAMA_SOURCE_SERVICE=5000110005
|
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)
|
# Partner gateway: domain:apiKey pairs, comma-separated (www. is stripped)
|
||||||
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
# Example: SMS_PARTNERS=baloutpastry.com:replace-with-long-random-secret
|
||||||
SMS_PARTNERS=
|
SMS_PARTNERS=
|
||||||
@@ -101,6 +102,11 @@ ARVAN_API_KEY=
|
|||||||
# Optional override; default https://napi.arvancloud.ir/cdn/4.0
|
# Optional override; default https://napi.arvancloud.ir/cdn/4.0
|
||||||
# ARVAN_API_BASE_URL=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})
|
# Public domain for platform invoice links (https://{domain}/invoices/{id})
|
||||||
INVOICE_PUBLIC_DOMAIN=meshkee.com
|
INVOICE_PUBLIC_DOMAIN=meshkee.com
|
||||||
# Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174
|
# Optional full origin for local (overrides domain + business host), e.g. https://meshkee.app:5174
|
||||||
|
|||||||
@@ -3,3 +3,4 @@ node_modules/
|
|||||||
dist/
|
dist/
|
||||||
.DS_Store
|
.DS_Store
|
||||||
*.log
|
*.log
|
||||||
|
tmp/
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Optional dark-theme logo for business profile / storefront dashboards
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS logo_dark_media_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
DROP CONSTRAINT IF EXISTS businesses_logo_dark_media_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD CONSTRAINT businesses_logo_dark_media_id_fkey
|
||||||
|
FOREIGN KEY (logo_dark_media_id) REFERENCES media (id) ON DELETE SET NULL;
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_view_kind') THEN
|
||||||
|
CREATE TYPE website_view_kind AS ENUM ('website', 'product', 'portfolio', 'blog');
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS website_content_views (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
view_kind website_view_kind NOT NULL,
|
||||||
|
entity_id BIGINT,
|
||||||
|
page_path TEXT,
|
||||||
|
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_content_views_business_kind_time
|
||||||
|
ON website_content_views (business_id, view_kind, viewed_at DESC);
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
-- Expand page-view kinds, rename events table, add lifetime counters.
|
||||||
|
-- Events retained max 6 months (purged by Nest cron). Counters kept forever.
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'website_page_kind') THEN
|
||||||
|
CREATE TYPE website_page_kind AS ENUM (
|
||||||
|
'home',
|
||||||
|
'portfolio_list',
|
||||||
|
'portfolio_detail',
|
||||||
|
'product_list',
|
||||||
|
'product_detail',
|
||||||
|
'store_item_list',
|
||||||
|
'store_item_detail',
|
||||||
|
'blog_list',
|
||||||
|
'blog_detail',
|
||||||
|
'video_list',
|
||||||
|
'video_detail'
|
||||||
|
);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Recreate events table with new kind enum (table is empty / low volume today).
|
||||||
|
CREATE TABLE IF NOT EXISTS website_page_views (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
page_kind website_page_kind NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
page_path TEXT,
|
||||||
|
viewed_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_page_views_business_kind_time
|
||||||
|
ON website_page_views (business_id, page_kind, viewed_at DESC);
|
||||||
|
|
||||||
|
-- Copy any legacy rows from website_content_views
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public' AND table_name = 'website_content_views'
|
||||||
|
) THEN
|
||||||
|
INSERT INTO website_page_views (business_id, page_kind, entity_id, page_path, viewed_at)
|
||||||
|
SELECT
|
||||||
|
business_id,
|
||||||
|
CASE view_kind::text
|
||||||
|
WHEN 'website' THEN 'home'::website_page_kind
|
||||||
|
WHEN 'product' THEN 'product_detail'::website_page_kind
|
||||||
|
WHEN 'portfolio' THEN 'portfolio_detail'::website_page_kind
|
||||||
|
WHEN 'blog' THEN 'blog_detail'::website_page_kind
|
||||||
|
ELSE 'home'::website_page_kind
|
||||||
|
END,
|
||||||
|
COALESCE(entity_id, 0),
|
||||||
|
page_path,
|
||||||
|
viewed_at
|
||||||
|
FROM website_content_views;
|
||||||
|
|
||||||
|
DROP TABLE website_content_views;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP TYPE IF EXISTS website_view_kind;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS website_page_view_counters (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
page_kind website_page_kind NOT NULL,
|
||||||
|
entity_id BIGINT NOT NULL DEFAULT 0,
|
||||||
|
total_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT uq_website_page_view_counters_biz_kind_entity
|
||||||
|
UNIQUE (business_id, page_kind, entity_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_website_page_view_counters_business_kind
|
||||||
|
ON website_page_view_counters (business_id, page_kind);
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Lifetime product analysis counters (visits, cart adds, units sold).
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS visit_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS cart_add_count BIGINT NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS sale_count BIGINT NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Store Let's Encrypt / TLS notAfter after a successful SSL issue.
|
||||||
|
-- List Valid/Invalid prefers this date; live probes run at most once per day.
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS ssl_expires_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domains_ssl_expires_at
|
||||||
|
ON domains (ssl_expires_at)
|
||||||
|
WHERE ssl_expires_at IS NOT NULL;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Business annual renewal date + user honorific titles.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_titles (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
slug VARCHAR(32) NOT NULL,
|
||||||
|
name_en VARCHAR(64) NOT NULL,
|
||||||
|
name_fa VARCHAR(64) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT user_titles_slug_unique UNIQUE (slug)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_user_titles_sort
|
||||||
|
ON user_titles (sort_order, id);
|
||||||
|
|
||||||
|
INSERT INTO user_titles (slug, name_en, name_fa, sort_order)
|
||||||
|
VALUES
|
||||||
|
('mr', 'Mr', 'آقای', 10),
|
||||||
|
('mrs', 'Mrs', 'خانم', 20),
|
||||||
|
('ms', 'Ms', 'خانم', 30),
|
||||||
|
('dr', 'Dr', 'دکتر', 40),
|
||||||
|
('eng', 'Eng.', 'مهندس', 50)
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS title_id BIGINT REFERENCES user_titles (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_users_title_id ON users (title_id);
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS annual_renewal_at TIMESTAMPTZ;
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- Default annual renewal to business created date when unset.
|
||||||
|
|
||||||
|
UPDATE businesses
|
||||||
|
SET annual_renewal_at = created_at
|
||||||
|
WHERE annual_renewal_at IS NULL;
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
-- Super Admin AI prompt templates (per website / domain), with public share ids.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS ai_prompt_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
public_id VARCHAR(32) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT ai_prompt_templates_public_id_unique UNIQUE (public_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompt_templates_domain_id
|
||||||
|
ON ai_prompt_templates (domain_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ai_prompt_templates_created_at
|
||||||
|
ON ai_prompt_templates (created_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION ai_prompt_templates_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS ai_prompt_templates_set_updated_at ON ai_prompt_templates;
|
||||||
|
CREATE TRIGGER ai_prompt_templates_set_updated_at
|
||||||
|
BEFORE UPDATE ON ai_prompt_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION ai_prompt_templates_set_updated_at();
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
-- Make AI prompt templates global; assign to websites via join + pack public id.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_ai_prompt_templates (
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
template_id BIGINT NOT NULL REFERENCES ai_prompt_templates (id) ON DELETE CASCADE,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (domain_id, template_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompt_templates_template_id
|
||||||
|
ON domain_ai_prompt_templates (template_id);
|
||||||
|
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD COLUMN IF NOT EXISTS ai_prompts_public_id VARCHAR(32);
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (
|
||||||
|
SELECT 1 FROM pg_constraint WHERE conname = 'domains_ai_prompts_public_id_unique'
|
||||||
|
) THEN
|
||||||
|
ALTER TABLE domains
|
||||||
|
ADD CONSTRAINT domains_ai_prompts_public_id_unique UNIQUE (ai_prompts_public_id);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Preserve any existing per-domain template links (from 075).
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'ai_prompt_templates' AND column_name = 'domain_id'
|
||||||
|
) THEN
|
||||||
|
INSERT INTO domain_ai_prompt_templates (domain_id, template_id, sort_order)
|
||||||
|
SELECT domain_id, id, 0
|
||||||
|
FROM ai_prompt_templates
|
||||||
|
WHERE domain_id IS NOT NULL
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
UPDATE domains d
|
||||||
|
SET ai_prompts_public_id = t.public_id
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (domain_id) domain_id, public_id
|
||||||
|
FROM ai_prompt_templates
|
||||||
|
WHERE public_id IS NOT NULL
|
||||||
|
ORDER BY domain_id, id
|
||||||
|
) t
|
||||||
|
WHERE d.id = t.domain_id
|
||||||
|
AND d.ai_prompts_public_id IS NULL;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE ai_prompt_templates DROP COLUMN IF EXISTS domain_id;
|
||||||
|
ALTER TABLE ai_prompt_templates DROP COLUMN IF EXISTS public_id;
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
-- Per-website AI prompt instances (editable copies) with their own public links.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS domain_ai_prompts (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
domain_id BIGINT NOT NULL REFERENCES domains (id) ON DELETE CASCADE,
|
||||||
|
source_template_id BIGINT REFERENCES ai_prompt_templates (id) ON DELETE SET NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
public_id VARCHAR(32) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
CONSTRAINT domain_ai_prompts_public_id_unique UNIQUE (public_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompts_domain_id
|
||||||
|
ON domain_ai_prompts (domain_id, sort_order, id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_domain_ai_prompts_source_template_id
|
||||||
|
ON domain_ai_prompts (source_template_id);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION domain_ai_prompts_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS domain_ai_prompts_set_updated_at ON domain_ai_prompts;
|
||||||
|
CREATE TRIGGER domain_ai_prompts_set_updated_at
|
||||||
|
BEFORE UPDATE ON domain_ai_prompts
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION domain_ai_prompts_set_updated_at();
|
||||||
|
|
||||||
|
-- Copy existing assignments into editable instances (one public id each).
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r RECORD;
|
||||||
|
pid TEXT;
|
||||||
|
BEGIN
|
||||||
|
IF EXISTS (
|
||||||
|
SELECT 1 FROM information_schema.tables
|
||||||
|
WHERE table_name = 'domain_ai_prompt_templates'
|
||||||
|
) THEN
|
||||||
|
FOR r IN
|
||||||
|
SELECT
|
||||||
|
l.domain_id,
|
||||||
|
l.template_id,
|
||||||
|
l.sort_order,
|
||||||
|
t.name,
|
||||||
|
t.body
|
||||||
|
FROM domain_ai_prompt_templates l
|
||||||
|
JOIN ai_prompt_templates t ON t.id = l.template_id
|
||||||
|
ORDER BY l.domain_id, l.sort_order, l.template_id
|
||||||
|
LOOP
|
||||||
|
LOOP
|
||||||
|
pid := encode(gen_random_bytes(12), 'hex');
|
||||||
|
EXIT WHEN NOT EXISTS (
|
||||||
|
SELECT 1 FROM domain_ai_prompts WHERE public_id = pid
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
|
||||||
|
INSERT INTO domain_ai_prompts (
|
||||||
|
domain_id, source_template_id, name, body, public_id, sort_order
|
||||||
|
) VALUES (
|
||||||
|
r.domain_id, r.template_id, r.name, r.body, pid, r.sort_order
|
||||||
|
);
|
||||||
|
END LOOP;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS domain_ai_prompt_templates;
|
||||||
@@ -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();
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
-- SMS templates per business (verified by super-admin → Meshkee service shortcode).
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS sms_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
body TEXT NOT NULL,
|
||||||
|
is_verified BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
verified_at TIMESTAMPTZ NULL,
|
||||||
|
verified_by BIGINT NULL REFERENCES users (id) ON DELETE SET NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_id
|
||||||
|
ON sms_templates (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_verified
|
||||||
|
ON sms_templates (business_id, is_verified);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sms_templates_created_at
|
||||||
|
ON sms_templates (created_at DESC);
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION sms_templates_set_updated_at()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = now();
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS sms_templates_set_updated_at ON sms_templates;
|
||||||
|
CREATE TRIGGER sms_templates_set_updated_at
|
||||||
|
BEFORE UPDATE ON sms_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION sms_templates_set_updated_at();
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
-- Per-business prepaid SMS credit (messages). Deducted when customer SMS is sent.
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_balance INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.sms_balance IS
|
||||||
|
'Prepaid SMS message credits for this business; decremented on customer SMS send.';
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- SMS template verification workflow: free | pending | verified
|
||||||
|
|
||||||
|
ALTER TABLE sms_templates
|
||||||
|
ADD COLUMN IF NOT EXISTS verification_status VARCHAR(32) NOT NULL DEFAULT 'free';
|
||||||
|
|
||||||
|
UPDATE sms_templates
|
||||||
|
SET verification_status = 'verified'
|
||||||
|
WHERE is_verified = TRUE;
|
||||||
|
|
||||||
|
ALTER TABLE sms_templates
|
||||||
|
DROP CONSTRAINT IF EXISTS sms_templates_verification_status_check;
|
||||||
|
|
||||||
|
ALTER TABLE sms_templates
|
||||||
|
ADD CONSTRAINT sms_templates_verification_status_check
|
||||||
|
CHECK (verification_status IN ('free', 'pending', 'verified'));
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sms_templates_verification_status
|
||||||
|
ON sms_templates (verification_status);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_sms_templates_business_status
|
||||||
|
ON sms_templates (business_id, verification_status);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- SMS balance is prepaid IRT (not message counts).
|
||||||
|
-- Billing: ceil(message_length / 64) * 400 IRT per recipient
|
||||||
|
-- (login OTP, customer SMS, partner/public SMS, etc.).
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.sms_balance IS
|
||||||
|
'Prepaid SMS balance in IRT. Each 64-character segment costs 400 IRT per recipient.';
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Lifetime count of SMS messages successfully billed to each business
|
||||||
|
-- (incremented on charge, decremented on refund). One unit = one recipient.
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_count INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_count IS
|
||||||
|
'Lifetime SMS messages billed to this business (one per recipient). Incremented on charge, decremented on refund.';
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Convert prepaid SMS balance from IRT to credit units (idempotent).
|
||||||
|
-- Previous billing: 400 IRT per 64-char segment.
|
||||||
|
-- New model: balance is SMS credits (1 credit = 1 segment); top-up at 500 IRT/credit.
|
||||||
|
-- Only convert rows that still look like IRT balances (>= 100_000). Already-converted
|
||||||
|
-- credit balances are below that and are left unchanged on re-run.
|
||||||
|
|
||||||
|
UPDATE businesses
|
||||||
|
SET sms_balance = sms_balance / 400
|
||||||
|
WHERE sms_balance >= 100000;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.sms_balance IS
|
||||||
|
'Prepaid SMS credit units. 1 unit = one 64-character segment per recipient. Top-up adds floor(IRT / 500) credits.';
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- Per-type lifetime SMS send counters (one unit = one recipient).
|
||||||
|
-- sms_sent_count remains the total across all types.
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_mass INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_singular INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_auth INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_auto_customer INTEGER NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS sms_sent_admin INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_mass IS
|
||||||
|
'Lifetime bulk/mass customer SMS messages billed (one per recipient).';
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_singular IS
|
||||||
|
'Lifetime single-recipient SMS messages billed (customer one-to-one, partner public).';
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_auth IS
|
||||||
|
'Lifetime auth/OTP SMS messages billed.';
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_auto_customer IS
|
||||||
|
'Lifetime automatic customer SMS (birthdays, rewards, …) — reserved.';
|
||||||
|
COMMENT ON COLUMN businesses.sms_sent_admin IS
|
||||||
|
'Lifetime admin notification SMS (new order, visits, …) — reserved.';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Optional SEO overrides for category pages (same keys as content metadata JSON).
|
||||||
|
ALTER TABLE categories
|
||||||
|
ADD COLUMN IF NOT EXISTS metadata JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN categories.metadata IS
|
||||||
|
'JSON bag for category SEO overrides: seoMetaTitle, seoMetaDescription';
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
-- Add search tags + Persian name on platform business categories
|
||||||
|
ALTER TABLE business_categories
|
||||||
|
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
||||||
|
|
||||||
|
ALTER TABLE business_categories
|
||||||
|
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(255);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_business_categories_tags
|
||||||
|
ON business_categories USING GIN (tags);
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
-- Marketplace / storefront banner image for a business (aspect 18:4.5 ≈ 4:1)
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD COLUMN IF NOT EXISTS banner_media_id BIGINT;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
DROP CONSTRAINT IF EXISTS businesses_banner_media_id_fkey;
|
||||||
|
|
||||||
|
ALTER TABLE businesses
|
||||||
|
ADD CONSTRAINT businesses_banner_media_id_fkey
|
||||||
|
FOREIGN KEY (banner_media_id) REFERENCES media (id) ON DELETE SET NULL;
|
||||||
|
|
||||||
|
COMMENT ON COLUMN businesses.banner_media_id IS
|
||||||
|
'Optional marketplace/storefront banner; recommended aspect ratio 18:4.5 (4:1).';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Business-level gallery (media_attachments with entity_type = gallery, entity_id = business_id)
|
||||||
|
|
||||||
|
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'gallery';
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Track public customer marketplace (user product) list/detail page views.
|
||||||
|
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_list';
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'user_product_detail';
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Count About / Contact / other static pages via static-images?pageKey=...
|
||||||
|
|
||||||
|
ALTER TYPE website_page_kind ADD VALUE IF NOT EXISTS 'static_page';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Used / second-hand flag on store listings (product-level).
|
||||||
|
ALTER TABLE store_items
|
||||||
|
ADD COLUMN IF NOT EXISTS is_used BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_items_business_is_used
|
||||||
|
ON store_items (business_id, is_used);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Free-text list field type for category technical forms (duplicatable values).
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE technical_field_type ADD VALUE 'multi_value';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
-- Meshkee CMS — invoice / template payment methods (نحوه ی پرداخت)
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- template payment methods (duplicatable free-text)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE invoice_template_payment_methods (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
template_id BIGINT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT invoice_template_payment_methods_template_id_fkey
|
||||||
|
FOREIGN KEY (template_id) REFERENCES invoice_templates (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT invoice_template_payment_methods_text_nonempty
|
||||||
|
CHECK (char_length(trim(text)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoice_template_payment_methods_template_id
|
||||||
|
ON invoice_template_payment_methods (template_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TRIGGER invoice_template_payment_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_template_payment_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- invoice payment methods (duplicatable free-text)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
CREATE TABLE invoice_payment_methods (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
invoice_id BIGINT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT invoice_payment_methods_invoice_id_fkey
|
||||||
|
FOREIGN KEY (invoice_id) REFERENCES invoices (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT invoice_payment_methods_text_nonempty
|
||||||
|
CHECK (char_length(trim(text)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_invoice_payment_methods_invoice_id
|
||||||
|
ON invoice_payment_methods (invoice_id, sort_order);
|
||||||
|
|
||||||
|
CREATE TRIGGER invoice_payment_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_payment_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
-- Reusable bank account templates (platform or business), parallel to invoice_item_templates.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS invoice_account_templates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
owner_scope invoice_owner_scope NOT NULL,
|
||||||
|
business_id BIGINT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
bank_name VARCHAR(255) NOT NULL,
|
||||||
|
account_holder_name VARCHAR(255) NULL,
|
||||||
|
card_number VARCHAR(64) NULL,
|
||||||
|
iban VARCHAR(64) NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_owner_scope
|
||||||
|
ON invoice_account_templates (owner_scope, sort_order);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_invoice_account_templates_business_id
|
||||||
|
ON invoice_account_templates (business_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS invoice_account_templates_set_updated_at ON invoice_account_templates;
|
||||||
|
CREATE TRIGGER invoice_account_templates_set_updated_at
|
||||||
|
BEFORE UPDATE ON invoice_account_templates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
-- Store loan / installment methods (اقساط و تسهیلات)
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS store_loan_methods (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_en VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
image_media_id BIGINT NULL REFERENCES media (id) ON UPDATE NO ACTION,
|
||||||
|
image_aspect_ratio VARCHAR(10) NOT NULL DEFAULT '1:1',
|
||||||
|
min_shopping_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
min_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
max_amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
interest NUMERIC(8, 4) NOT NULL,
|
||||||
|
return_months_min INT NOT NULL,
|
||||||
|
return_months_max INT NOT NULL,
|
||||||
|
return_months_step INT NOT NULL DEFAULT 3,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_business_id
|
||||||
|
ON store_loan_methods (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_image_media_id
|
||||||
|
ON store_loan_methods (image_media_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_store_loan_methods_business_sort_order
|
||||||
|
ON store_loan_methods (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS store_loan_methods_set_updated_at ON store_loan_methods;
|
||||||
|
CREATE TRIGGER store_loan_methods_set_updated_at
|
||||||
|
BEFORE UPDATE ON store_loan_methods
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Product catalog PDF (optional document on products) + media_type.document
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TYPE media_type ADD VALUE IF NOT EXISTS 'document';
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD COLUMN IF NOT EXISTS catalog_media_id BIGINT NULL;
|
||||||
|
|
||||||
|
DO $$ BEGIN
|
||||||
|
ALTER TABLE products
|
||||||
|
ADD CONSTRAINT products_catalog_media_id_fkey
|
||||||
|
FOREIGN KEY (catalog_media_id) REFERENCES media (id) ON UPDATE NO ACTION;
|
||||||
|
EXCEPTION
|
||||||
|
WHEN duplicate_object THEN NULL;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_products_catalog_media_id
|
||||||
|
ON products (catalog_media_id);
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
-- Shipping cost groups (هزینه ارسال) + per-city overrides + category assignment
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL REFERENCES businesses (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
name_en VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NULL,
|
||||||
|
methods TEXT[] NOT NULL DEFAULT '{}',
|
||||||
|
default_amount NUMERIC(12, 2) NOT NULL DEFAULT 0,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_id
|
||||||
|
ON shipping_cost_groups (business_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_groups_business_sort_order
|
||||||
|
ON shipping_cost_groups (business_id, sort_order);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_groups_set_updated_at ON shipping_cost_groups;
|
||||||
|
CREATE TRIGGER shipping_cost_groups_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_groups
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_city_rates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
city_id BIGINT NOT NULL REFERENCES cities (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
amount NUMERIC(12, 2) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
CONSTRAINT shipping_cost_group_city_rates_unique UNIQUE (shipping_cost_group_id, city_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_group_id
|
||||||
|
ON shipping_cost_group_city_rates (shipping_cost_group_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_city_rates_city_id
|
||||||
|
ON shipping_cost_group_city_rates (city_id);
|
||||||
|
|
||||||
|
DROP TRIGGER IF EXISTS shipping_cost_group_city_rates_set_updated_at ON shipping_cost_group_city_rates;
|
||||||
|
CREATE TRIGGER shipping_cost_group_city_rates_set_updated_at
|
||||||
|
BEFORE UPDATE ON shipping_cost_group_city_rates
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shipping_cost_group_categories (
|
||||||
|
shipping_cost_group_id BIGINT NOT NULL REFERENCES shipping_cost_groups (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
category_id BIGINT NOT NULL REFERENCES categories (id) ON DELETE CASCADE ON UPDATE NO ACTION,
|
||||||
|
PRIMARY KEY (shipping_cost_group_id, category_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_shipping_cost_group_categories_category_id
|
||||||
|
ON shipping_cost_group_categories (category_id);
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Default shipping cost group per business (fallback for unassigned categories)
|
||||||
|
|
||||||
|
ALTER TABLE shipping_cost_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS is_default BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_shipping_cost_groups_business_default
|
||||||
|
ON shipping_cost_groups (business_id)
|
||||||
|
WHERE is_default = TRUE;
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Freight-collect (پس کرایه): shipping is free at checkout but labeled for the customer
|
||||||
|
|
||||||
|
ALTER TABLE shipping_cost_groups
|
||||||
|
ADD COLUMN IF NOT EXISTS pas_kraye BOOLEAN NOT NULL DEFAULT FALSE;
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
-- Roll call / daily work log for business admins (customer dashboard)
|
||||||
|
|
||||||
|
CREATE TABLE roll_calls (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
business_id BIGINT NOT NULL,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
work_date DATE NOT NULL,
|
||||||
|
start_time VARCHAR(5) NOT NULL,
|
||||||
|
end_time VARCHAR(5) NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT roll_calls_business_id_fkey
|
||||||
|
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_calls_user_id_fkey
|
||||||
|
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_calls_business_user_date_unique
|
||||||
|
UNIQUE (business_id, user_id, work_date),
|
||||||
|
CONSTRAINT roll_calls_start_time_format
|
||||||
|
CHECK (start_time ~ '^\d{2}:\d{2}$'),
|
||||||
|
CONSTRAINT roll_calls_end_time_format
|
||||||
|
CHECK (end_time ~ '^\d{2}:\d{2}$')
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_roll_calls_business_id ON roll_calls (business_id);
|
||||||
|
CREATE INDEX idx_roll_calls_user_id ON roll_calls (user_id);
|
||||||
|
CREATE INDEX idx_roll_calls_business_user_date
|
||||||
|
ON roll_calls (business_id, user_id, work_date DESC);
|
||||||
|
|
||||||
|
CREATE TRIGGER roll_calls_set_updated_at
|
||||||
|
BEFORE UPDATE ON roll_calls
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION set_updated_at();
|
||||||
|
|
||||||
|
CREATE TABLE roll_call_tasks (
|
||||||
|
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||||
|
roll_call_id BIGINT NOT NULL,
|
||||||
|
title VARCHAR(500) NOT NULL,
|
||||||
|
sort_order INT NOT NULL DEFAULT 0,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
|
||||||
|
CONSTRAINT roll_call_tasks_roll_call_id_fkey
|
||||||
|
FOREIGN KEY (roll_call_id) REFERENCES roll_calls (id) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT roll_call_tasks_title_nonempty
|
||||||
|
CHECK (char_length(trim(title)) > 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_roll_call_tasks_roll_call_id ON roll_call_tasks (roll_call_id);
|
||||||
|
|
||||||
|
INSERT INTO permissions (name, slug, group_name, description) VALUES
|
||||||
|
('View roll calls', 'roll_calls.read', 'roll_calls', 'View roll call / work day logs'),
|
||||||
|
('Create roll calls', 'roll_calls.create', 'roll_calls', 'Create roll call / work day logs'),
|
||||||
|
('Update roll calls', 'roll_calls.update', 'roll_calls', 'Update roll call / work day logs'),
|
||||||
|
('Delete roll calls', 'roll_calls.delete', 'roll_calls', 'Delete roll call / work day logs')
|
||||||
|
ON CONFLICT (slug) DO NOTHING;
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'roll_calls.%'
|
||||||
|
WHERE r.slug IN ('business_owner', 'owner', 'admin')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
-- Roll call is for every staff role, including editor and viewer.
|
||||||
|
|
||||||
|
INSERT INTO role_permissions (role_id, permission_id)
|
||||||
|
SELECT r.id, p.id
|
||||||
|
FROM roles r
|
||||||
|
JOIN permissions p ON p.slug LIKE 'roll_calls.%'
|
||||||
|
WHERE r.slug IN ('editor', 'viewer', 'business_staff')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
@@ -153,6 +153,19 @@ server {
|
|||||||
|
|
||||||
client_max_body_size 15M;
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:3000;
|
proxy_pass http://127.0.0.1:3000;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -160,6 +173,7 @@ server {
|
|||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_read_timeout 120s;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|||||||
+80
-7
@@ -1,7 +1,7 @@
|
|||||||
# Meshkee CMS API — Project Context
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## 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) |
|
| `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` |
|
| `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) |
|
| `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.
|
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 |
|
| Enum | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
||||||
|
| `TranslationEntityType` | `product`, `category` |
|
||||||
|
| `TranslationLocale` | `en`, `ar` |
|
||||||
|
| `TranslationStatus` | `pending`, `ready`, `stale`, `failed` |
|
||||||
| `ContentStatus` | `draft`, `published`, `archived` |
|
| `ContentStatus` | `draft`, `published`, `archived` |
|
||||||
| `VariationType` | `color`, `size`, `custom` |
|
| `VariationType` | `color`, `size`, `custom` |
|
||||||
| `CityLevel` | `country`, `province`, `city`, `district` |
|
| `CityLevel` | `country`, `province`, `city`, `district` |
|
||||||
@@ -210,6 +214,8 @@ StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation c
|
|||||||
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
||||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
|
|
||||||
|
Business 1──* StoreLoanMethod (installments / facilities; module `store_installments`)
|
||||||
|
|
||||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||||
UserProduct → City (country, city, optional district)
|
UserProduct → City (country, city, optional district)
|
||||||
@@ -262,10 +268,27 @@ All routes are prefixed with `/api/v1`.
|
|||||||
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
| POST | `/auth/verify-otp` | Verify OTP (marks cell verified; no tokens) |
|
||||||
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
| POST | `/auth/handoff/consume` | One-time SSO ticket → tokens (customer → business dashboard) |
|
||||||
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
| GET | `/tenants/:host` | Resolve business from domain (`specialProductsSource`, `enabledModules`, `homeCharts`, `ePayment`) |
|
||||||
| GET | `/tenants/:host/sitemap.xml` | XML sitemap (static pages from synced manifest + published products/blogs/portfolios) |
|
| GET | `/tenants/:host/sitemap.xml` | Sitemap **index** → main + dynamic child sitemaps |
|
||||||
|
| GET | `/tenants/:host/sitemap-main.xml` | Static pages + `/products/category/{id}/{faSlug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-products.xml` | Published products `/products/{id}/{faSlug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-blogs.xml` | Published blogs `/blog/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-portfolios.xml` | Published portfolios `/portfolio/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-videos.xml` | Published videos `/videos/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-instructions.xml` | Published instructions `/instruction/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-workshops.xml` | Published workshops `/workshops/{slug}` |
|
||||||
|
| GET | `/tenants/:host/sitemap-user-products.xml` | Published user products `/user-products/{id}/{slug}` (module `customer_products`) |
|
||||||
| GET | `/tenants/:host/robots.txt` | robots.txt pointing to apex `/sitemap.xml` |
|
| 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) |
|
||||||
|
| GET | `/tenants/:host/blogs/by-id/:blogId` | Public blog by id |
|
||||||
|
| GET | `/tenants/:host/portfolios/by-id/:portfolioId` | Public portfolio by id |
|
||||||
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
| POST | `/businesses/:id/website/sitemap/sync` | Import manifest from `GET https://{domain}/meshkee/sitemap-config.json` |
|
||||||
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
| GET | `/tenants/:host/store-specials` | Active store specials (`source` is `product` or `store_item`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods` | Active installment/credit methods (`enabled` + `items[]`; needs `store` + `store_installments`) |
|
||||||
|
| GET | `/tenants/:host/store-loan-methods/:methodId` | One active method |
|
||||||
|
| POST | `/tenants/:host/store-loan-methods/calculate` | Installment plan — body requires `methodId` (pick method first if multiple) |
|
||||||
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
| GET | `/tenants/:host/website/category-groups` | Homepage category rows |
|
||||||
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
| GET | `/tenants/:host/website/brand-groups` | Homepage brand rows |
|
||||||
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
| GET | `/tenants/:host/website/sliders` | Homepage sliders with slides |
|
||||||
@@ -311,6 +334,10 @@ Pattern: `/businesses/:businessId/<resource>`
|
|||||||
| GET | `/categories/color-presets` | Predefined color palette |
|
| GET | `/categories/color-presets` | Predefined color palette |
|
||||||
| GET/PUT | `/categories/:id/variations` | Category variation options |
|
| GET/PUT | `/categories/:id/variations` | Category variation options |
|
||||||
| GET/PUT | `/categories/:id/technical-form` | Technical form definition |
|
| 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
|
#### Products — notable sub-routes
|
||||||
|
|
||||||
@@ -319,6 +346,10 @@ Pattern: `/businesses/:businessId/<resource>`
|
|||||||
| GET/PUT | `/products/:id/variations` | Which category variation options apply to this product |
|
| 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/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
|
||||||
| GET/PUT | `/products/:id/technical-info` | Product technical data |
|
| 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)
|
#### My user products (customer — JWT, must be business customer)
|
||||||
|
|
||||||
@@ -337,9 +368,11 @@ Base: `/tenants/:host/user-products`
|
|||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
|--------|------|-------------|
|
||||||
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) |
|
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination); items include `pathSlug` |
|
||||||
| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values |
|
| GET | `/by-id/:id` | Details by id (preferred for `/user-products/{id}/{pathSlug}` pages) |
|
||||||
| GET | `/:slug/technical-info` | Category technical form + values |
|
| 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)
|
#### Cart (customer — JWT, must be business customer)
|
||||||
|
|
||||||
@@ -427,7 +460,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
|
|||||||
- Dashboard SSO: `POST /auth/handoff` (JWT) stores a one-time Redis ticket (`sso:handoff:{ticket}`, 60s). Business dashboard calls `POST /auth/handoff/consume` and receives tokens.
|
- 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`
|
- JWT payload: `sub`, `cellNumber`, `roles`, `dashboard`, `type`
|
||||||
- SMS provider: Gama (`sms.igama.ir`) SendQuick via service shortcode (`SMS_GAMA_*`)
|
- 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -439,6 +472,7 @@ Each resource typically has: `read`, `create`, `update`, `delete` (+ `publish` f
|
|||||||
- One category per product (via `CategoryAssignment`)
|
- One category per product (via `CategoryAssignment`)
|
||||||
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
|
- Content JSON: `{ nameFa, html }`; metadata JSON: `{ tags }`
|
||||||
- i18n: Persian name in `content.nameFa`, summary in `description`
|
- 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)
|
### Category variations (product categories)
|
||||||
|
|
||||||
@@ -489,6 +523,42 @@ Stored in `businesses.settings.website.specialProductsSource` (`product` | `stor
|
|||||||
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
- Dashboard: `PATCH /businesses/:id/settings` with `{ website: { specialProductsSource } }`.
|
||||||
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
- Public: `GET /tenants/:host` includes `specialProductsSource`; `GET /tenants/:host/store-specials` includes top-level `source`.
|
||||||
|
|
||||||
|
### Store loan methods (installments & facilities)
|
||||||
|
|
||||||
|
Requires modules `store` + `store_installments` (opt-in). Dashboard: Store → اقساط و تسهیلات.
|
||||||
|
|
||||||
|
| Field | Notes |
|
||||||
|
|-------|-------|
|
||||||
|
| `nameFa` / `nameEn` | Display names |
|
||||||
|
| `minShoppingAmount` | Minimum purchase total to use this method |
|
||||||
|
| `minAmount` / `maxAmount` | Allowed credit range |
|
||||||
|
| `interest` | Flat annual %; fee = credit × (interest/100) × (months/12) |
|
||||||
|
| `returnMonthsMin` / `Max` / `Step` | Term options (step ∈ 2,3,4,6,12) |
|
||||||
|
| `imageMediaId` / `imageAspectRatio` | Optional image (`1:1` or `3:2`) |
|
||||||
|
|
||||||
|
**Dashboard (auth, `products.read` / `products.update`):**
|
||||||
|
- `GET/POST /businesses/:businessId/store-loan-methods`
|
||||||
|
- `GET/PATCH/DELETE /businesses/:businessId/store-loan-methods/:methodId`
|
||||||
|
|
||||||
|
**Public website:**
|
||||||
|
- `GET /tenants/:host/store-loan-methods` → `{ enabled, items[] }` (`enabled: false` + empty when module off)
|
||||||
|
- `GET /tenants/:host/store-loan-methods/:methodId` → `{ method }` (active only)
|
||||||
|
- `POST /tenants/:host/store-loan-methods/calculate` → `{ method, plan }` — **always requires `methodId`**
|
||||||
|
|
||||||
|
Calculator UX when a site has multiple methods: list first → if `items.length > 1`, ask the shopper which method → then collect purchase total / credit / months → `POST .../calculate`. If exactly one method, auto-select it. Never calculate without a chosen `methodId`.
|
||||||
|
|
||||||
|
```json
|
||||||
|
POST /tenants/:host/store-loan-methods/calculate
|
||||||
|
{
|
||||||
|
"methodId": "1",
|
||||||
|
"totalValue": 5000000,
|
||||||
|
"creditAmount": 2000000,
|
||||||
|
"months": 12
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`plan` includes `cashPayment`, `interestFee`, `totalRepay`, and monthly `installments[]` (`index`, `dueDate`, `amount`). Public items also include `returnMonthOptions[]`.
|
||||||
|
|
||||||
**Example — batch create variants for a product:**
|
**Example — batch create variants for a product:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -629,7 +699,8 @@ See `.env.example` for the full list. Key groups:
|
|||||||
|
|
||||||
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
- Multi-tenant auth (register, login, passwordless OTP login, reset password via SMS, profile)
|
||||||
- Super admin: users, businesses, domains, system business categories
|
- Super admin: users, businesses, domains, system business categories
|
||||||
- Super admin add/update domain upserts ArvanCloud DNS (`@` ANAME, `www`/`business`/`customer`/`api` CNAMEs)
|
- Super admin add/update domain upserts tenant DNS via ArvanCloud or Cloudflare (`@` ANAME/CNAME, `www`/`business`/`customer`/`api` CNAMEs; Cloudflare DNS-only)
|
||||||
|
- Super admin Websites ⋯ **Email DNS** upserts Stalwart mail records (mail A `185.214.101.41`, MX `mail.meshkee.com`, SPF `v=spf1 mx -all`, RSA DKIM required, Ed25519 DKIM optional, autoconfig/autodiscover) on Arvan or Cloudflare; removes extra MX, `dkim._domainkey`, and `_dmarc`
|
||||||
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
- Super admin: selective migrate-from-old + purge-data (portfolio categories + portfolios; oversized images resized to max 1280×1280; purge removes portfolios + images)
|
||||||
- Business team management
|
- Business team management
|
||||||
- Media upload (S3 + Sharp)
|
- Media upload (S3 + Sharp)
|
||||||
@@ -637,6 +708,8 @@ See `.env.example` for the full list. Key groups:
|
|||||||
- Products CRUD
|
- Products CRUD
|
||||||
- Product variation values (which options a product offers)
|
- Product variation values (which options a product offers)
|
||||||
- Store items / product variants (price, stock, SKU)
|
- Store items / product variants (price, stock, SKU)
|
||||||
|
- Store loan methods / installments (`store_installments`): dashboard CRUD + public list/get/calculate
|
||||||
|
- Torob Product API v3 (`POST /tenants/:host/torob_api/v3/products`) for store-module websites; nginx proxies `/torob_api/v3/products` on the shop apex
|
||||||
- Shopping cart + checkout + orders (customer + admin)
|
- Shopping cart + checkout + orders (customer + admin)
|
||||||
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
- Online e-payment (Mellat + ZarinPal; stubs for SEP / Snapp Pay / DigiPay)
|
||||||
- Product technical info
|
- Product technical info
|
||||||
|
|||||||
+336
-33
@@ -22,30 +22,199 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
|||||||
### Hard rules
|
### Hard rules
|
||||||
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
|
1. Resolve tenant first: `GET /tenants/<WEBSITE_DOMAIN>` → save `businessId` from `id`.
|
||||||
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
|
2. All public content uses `/tenants/<WEBSITE_DOMAIN>/...` (no auth).
|
||||||
3. Cart, orders, favorites use `/businesses/<businessId>/...` with `Authorization: Bearer <accessToken>`.
|
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. 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.
|
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. Cell numbers are E.164 (`+98912...`).
|
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. Do not call dashboard/CMS routes (`/businesses/.../products` write APIs, media upload, domain-admin, etc.).
|
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. **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
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
1. `GET /tenants/{domain}` → branding + `businessId` + `specialProductsSource` (`product` or `store_item`)
|
||||||
2. 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`)
|
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. 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)
|
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. 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).
|
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. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
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.
|
||||||
- For online pay: `payment.type = "e_payment_gate"`, `gatewayType` (e.g. `"mellat"` or `"zarinpal"`), and absolute `returnUrl`
|
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.
|
||||||
- Response includes `payment.redirect` `{ method, url, fields }` — POST/redirect shopper to the bank
|
|
||||||
- Bank callback hits API then redirects to `returnUrl?status=success|failed&orderId=…`
|
### Favicon + logos
|
||||||
- Enabled gateways: `GET /tenants/{domain}` → `ePayment`, or `GET /businesses/{businessId}/payments/methods`
|
- `GET /tenants/{domain}/website/favicon` → `{ faviconUrl, logoUrl, logoDarkUrl, hasDedicatedFavicon }`
|
||||||
|
- Use `faviconUrl` in layout metadata (Next.js: `icons: [{ url: faviconUrl, type: 'image/png' }]` when set).
|
||||||
|
- 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)
|
### User products (customer listings)
|
||||||
Public marketplace listings owned by customers — not catalog `products`.
|
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` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination). Each item includes `id`, `pathSlug`, `titleFa`/`titleEn`.
|
||||||
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
|
- `GET /tenants/{domain}/user-products/by-id/{id}` — **preferred** for storefront pages (slug segment is SEO-only)
|
||||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
- `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.
|
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)
|
||||||
|
**Do not** render only `technicalValues` from the detail endpoint — users will see bare values (e.g. `SAMP`, `1992`) with no labels.
|
||||||
|
|
||||||
|
| Detail page | Specs endpoint (labels + values) |
|
||||||
|
|-------------|----------------------------------|
|
||||||
|
| `GET .../products/{slug}` or `.../products/by-id/{id}` | `GET .../products/{slug}/technical-info` or `.../products/by-id/{id}/technical-info` |
|
||||||
|
| `GET .../user-products/by-id/{id}` (or `{slug}`) | `GET .../user-products/by-id/{id}/technical-info` (or `{slug}/technical-info`) |
|
||||||
|
|
||||||
|
**How to render:**
|
||||||
|
1. Call `technical-info` (same slug/id as the detail page).
|
||||||
|
2. For each `values[]` entry, find `form.fields` where `field.id === value.fieldId`.
|
||||||
|
3. Show `field.label` (or localized label if the site uses FA/EN) next to the value (`textValue`, or resolve option labels from `field.options` when `optionId` / `optionIds` are set).
|
||||||
|
4. Follow `form.fields` `sortOrder` for display order.
|
||||||
|
5. If `form` is null/empty or `values` is empty, hide the technical section.
|
||||||
|
|
||||||
|
**Wrong:** `GET .../product-category-variation-fields?categoryId=…` (does not exist → 404).
|
||||||
|
**Wrong:** expecting `fieldName` / `fieldNameFa` on each `technicalValues` item in the product detail response.
|
||||||
|
|
||||||
|
### Analytics (dashboard charts)
|
||||||
|
Page views are recorded **automatically** for roughly one count per page load:
|
||||||
|
|
||||||
|
- **Home** — `GET /tenants/{domain}/website/static-images?pageKey=home`
|
||||||
|
- **About / Contact / other static pages** — `GET .../static-images?pageKey=about` (or `contact`, etc.) → counted as `static_page` with `path=/about`
|
||||||
|
- **Content detail** — product, user-product, blog, portfolio, video, and store-item **detail** GETs
|
||||||
|
|
||||||
|
List APIs (`products`, `blogs`, …) and homepage sliders **do not** count (avoids multi-API inflation). Known bots (and empty User-Agent) are skipped. A browser refresh counts again.
|
||||||
|
|
||||||
|
Call `static-images?pageKey=…` on About/Contact (and similar) so those pages appear in dashboard statistics.
|
||||||
|
|
||||||
|
Optional explicit record (custom pages without static-images):
|
||||||
|
- `POST /tenants/{domain}/analytics/views` body `{ "kind": "home"|"static_page"|"blog_detail"|"product_detail"|..., "entityId"?: "...", "path"?: "/blog/my-post" }`
|
||||||
|
- `kind` values: `home`, `static_page`, `portfolio_list`, `portfolio_detail`, `product_list`, `product_detail`, `store_item_list`, `store_item_detail`, `blog_list`, `blog_detail`, `video_list`, `video_detail`, `user_product_list`, `user_product_detail` (legacy aliases `website`/`product`/`portfolio`/`blog` still accepted). Prefer detail / `home` / `static_page` for real page visits.
|
||||||
|
- Events kept ~6 months for charts; lifetime totals kept forever in counters.
|
||||||
|
- The business dashboard **Product views** chart counts both `product_detail` and `user_product_detail`.
|
||||||
|
- The **Pages** statistics row counts `static_page` (about, contact, …).
|
||||||
|
|
||||||
### Static images
|
### Static images
|
||||||
Named slots the business dashboard can replace. Fetch once per page:
|
Named slots the business dashboard can replace. Fetch once per page:
|
||||||
- `GET /tenants/{domain}/website/static-images` — all slots
|
- `GET /tenants/{domain}/website/static-images` — all slots
|
||||||
@@ -85,35 +254,169 @@ Use `slot.key` in the placeholder. Match `pageKey` to the website page. For a si
|
|||||||
|
|
||||||
`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs.
|
`kind` is `single` (one image) or `list` (duplicatable). For a fixed row, set `itemCount` (e.g. `2`). Leave `itemCount` null for an unbounded slider. `aspectRatio` must look like `16:9`. Do not invent CMS/upload APIs.
|
||||||
|
|
||||||
### Sitemap (SEO)
|
### Sitemap + robots.txt (SEO)
|
||||||
|
|
||||||
Meshkee generates `https://<WEBSITE_DOMAIN>/sitemap.xml` on the API from published CMS content plus a manifest your site publishes. Nginx on the storefront proxies `/sitemap.xml` and `/robots.txt` to the API — do not hardcode a static sitemap file in the repo unless you know nginx is not proxying yet.
|
Meshkee generates both files on the API. Nginx on the storefront proxies them — **do not** ship `public/sitemap.xml` or `public/robots.txt` (and do not add Next.js `app/sitemap.ts` / `app/robots.ts` that override these paths).
|
||||||
|
|
||||||
**Publish route manifest** so the business dashboard can sync static pages and URL templates:
|
| URL on this site | Served by |
|
||||||
|
|------------------|-----------|
|
||||||
|
| `https://<WEBSITE_DOMAIN>/sitemap.xml` | Meshkee API (proxied) |
|
||||||
|
| `https://<WEBSITE_DOMAIN>/robots.txt` | Meshkee API (proxied) — includes `Sitemap: https://<WEBSITE_DOMAIN>/sitemap.xml` |
|
||||||
|
|
||||||
|
**What the website must publish** (for static pages only):
|
||||||
|
|
||||||
`GET https://<WEBSITE_DOMAIN>/meshkee/sitemap-config.json`
|
`GET https://<WEBSITE_DOMAIN>/meshkee/sitemap-config.json`
|
||||||
|
|
||||||
|
Prefer generating this **at build time from app routes** (scan public static pages). Do not hand-maintain long lists in prompts.
|
||||||
|
|
||||||
|
Minimal example:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"baseUrl": "https://sanihome.ir",
|
"baseUrl": "https://<WEBSITE_DOMAIN>",
|
||||||
"staticPages": [
|
"staticPages": [
|
||||||
{ "path": "/", "changefreq": "daily", "priority": 1.0 },
|
{ "path": "/", "changefreq": "daily", "priority": 1.0 },
|
||||||
{ "path": "/about", "priority": 0.6 },
|
{ "path": "/about", "priority": 0.6 },
|
||||||
{ "path": "/contact", "priority": 0.5 },
|
{ "path": "/contact", "priority": 0.5 }
|
||||||
{ "path": "/blog", "priority": 0.7 }
|
]
|
||||||
],
|
|
||||||
"templates": {
|
|
||||||
"product": "/products/{slug}",
|
|
||||||
"blog": "/blog/{slug}",
|
|
||||||
"portfolio": "/portfolios/{slug}"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- `path` must start with `/`. Do not include checkout, login, or customer-dashboard URLs.
|
- Include public static routes automatically; exclude login, checkout, cart, account, and admin paths.
|
||||||
- Each `templates` value must include `{slug}` and match how this site routes detail pages.
|
- **Canonical detail URLs (Meshkee default for all sites):**
|
||||||
- After deploy, the business owner syncs from **Website → Settings** in the dashboard.
|
- 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).
|
||||||
- Dynamic URLs (products, blogs, portfolios) come from the CMS; only paths/templates are declared here.
|
- 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/{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)
|
||||||
|
- `/sitemap-main.xml` — static pages + product categories
|
||||||
|
- `/sitemap-products.xml` — published products (when products module is enabled)
|
||||||
|
- `/sitemap-blogs.xml` — published blogs (when blog module is enabled)
|
||||||
|
- `/sitemap-portfolios.xml` — published portfolios (when portfolio module is enabled)
|
||||||
|
- `/sitemap-videos.xml` — published videos (when videos module is enabled)
|
||||||
|
- `/sitemap-instructions.xml` — published instructions (when instructions module is enabled)
|
||||||
|
- `/sitemap-workshops.xml` — published workshops (when workshops module is enabled)
|
||||||
|
- `/sitemap-user-products.xml` — published user products / customer listings (when `customer_products` module is enabled)
|
||||||
|
- `/robots.txt` — points at `/sitemap.xml`
|
||||||
|
|
||||||
|
### On-page SEO (every public page)
|
||||||
|
|
||||||
|
These are **required on every crawlable page** (home, listing, product/blog/portfolio detail, about, contact, etc.). Auth/checkout may be noindex.
|
||||||
|
|
||||||
|
**Document title (`<title>`)**
|
||||||
|
- Unique per page; include the primary topic + business name when space allows.
|
||||||
|
- Prefer CMS fields when present (`title`, `nameFa`/`nameEn`, product title, blog title). Never leave the Next.js default title.
|
||||||
|
|
||||||
|
**Meta description**
|
||||||
|
- Unique `<meta name="description" content="…">` on every public page (roughly 120–160 characters).
|
||||||
|
- Prefer CMS summary/excerpt/description when available; otherwise write a short page-specific sentence. Never empty, never identical across all pages.
|
||||||
|
|
||||||
|
**Headings**
|
||||||
|
- Exactly **one** `<h1>` per page — the main topic (product name, blog title, page title). Do not hide it with CSS-only “fake” headings.
|
||||||
|
- Use `<h2>` (then `<h3>`…) for real section structure under the H1. Do not skip levels for styling (don’t use H4 as a visual label without H2/H3).
|
||||||
|
- Do not use headings for nav logos, button labels, or decorative text.
|
||||||
|
|
||||||
|
**Images**
|
||||||
|
- Every meaningful `<img>` / Next.js `Image` must have a non-empty **`alt`** describing the image (product name, slide title, banner purpose).
|
||||||
|
- Prefer CMS title / `titleFa` / `titleEn` / media alt when available; for decorative icons use `alt=""` only when the image adds no information.
|
||||||
|
- Never leave missing `alt` on content images (hero, product gallery, blog cover, static-image slots, sliders).
|
||||||
|
|
||||||
|
**Open Graph (recommended)**
|
||||||
|
- Set `og:title`, `og:description`, and `og:image` on important pages (home + detail pages) from CMS media when available.
|
||||||
|
|
||||||
|
### Site-wide meta tags + trust badges (eNamad / Samandehi)
|
||||||
|
|
||||||
|
Business admins configure this under **Website → Tags & Badges**.
|
||||||
|
|
||||||
|
1. `GET /tenants/{domain}/website/business-info`
|
||||||
|
2. For each item in `metaTags` (array of `{ html }`), emit the full HTML once in the root layout `<head>`.
|
||||||
|
|
||||||
|
```html
|
||||||
|
{html}
|
||||||
|
```
|
||||||
|
|
||||||
|
Example `html` value: `<meta name="google-site-verification" content="…">` (also supports `property`, `http-equiv`, etc.). Parse attributes into a `<meta />` element, or inject the sanitized string into `<head>`. Do **not** wrap it again as `name`/`content` — `html` is already the whole tag. The dashboard “Name” field is an admin label only and is **not** in this public payload.
|
||||||
|
|
||||||
|
3. For each item in `trustBadges` (array of `{ kind, embedHtml }`), render `embedHtml` in the site footer (e.g. `dangerouslySetInnerHTML`). These are HTML widgets only (not meta). Scripts are stripped by the API — still treat the HTML as CMS-controlled content.
|
||||||
|
|
||||||
|
- When arrays are empty, omit tags / footer badges.
|
||||||
|
- Do **not** hardcode eNamad/Samandehi HTML in the website repo when these fields are present.
|
||||||
|
|
||||||
|
### Site-wide Schema.org JSON-LD (Organization / LocalBusiness)
|
||||||
|
|
||||||
|
Business admins configure this under **Website → Settings → Structured data**. The API builds a ready `jsonLd` object for you.
|
||||||
|
|
||||||
|
1. `GET /tenants/{domain}/website/business-info`
|
||||||
|
2. If `schema.enabled` and `schema.jsonLd` is an object, render once in the root layout (or every public page):
|
||||||
|
|
||||||
|
```html
|
||||||
|
<script type="application/ld+json">
|
||||||
|
…paste schema.jsonLd…
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
In Next.js App Router, put it in the root layout via `<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(schema.jsonLd) }} />` when `jsonLd` is non-null.
|
||||||
|
|
||||||
|
- Do **not** invent Organization fields locally when `jsonLd` is present — use the CMS object.
|
||||||
|
- When `schema.enabled` is false, `jsonLd` is `null` — omit the script tag.
|
||||||
|
- Tenant resolve also exposes `{ schema: { enabled, type } }` for lightweight checks; full payload is on business-info.
|
||||||
|
- Social profile URLs from business profile are merged into `sameAs` automatically; admin can add extra https URLs.
|
||||||
|
|
||||||
|
### Product + blog Schema.org JSON-LD (auto)
|
||||||
|
|
||||||
|
When **Website → Settings → Structured data** is enabled, detail endpoints include a ready `schema.jsonLd` (no admin fields to edit):
|
||||||
|
|
||||||
|
| Page | Endpoint | `@type` |
|
||||||
|
|------|----------|---------|
|
||||||
|
| Product detail | `GET /tenants/{domain}/products/by-id/{id}` (or by slug) | `Product` (+ `Offer` when store min price exists) |
|
||||||
|
| Blog detail | `GET /tenants/{domain}/blogs/{slug}` (or by id) | `BlogPosting` |
|
||||||
|
|
||||||
|
On the product/blog page:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
{product.schema?.jsonLd ? (
|
||||||
|
<script
|
||||||
|
type="application/ld+json"
|
||||||
|
dangerouslySetInnerHTML={{ __html: JSON.stringify(product.schema.jsonLd) }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
```
|
||||||
|
|
||||||
|
- Emit **in addition to** the site-wide Organization/LocalBusiness script from business-info.
|
||||||
|
- When structured data is disabled, `schema.jsonLd` is `null` — omit the tag.
|
||||||
|
- Do not invent Product/Article fields locally when `jsonLd` is present.
|
||||||
|
|
||||||
|
### Per-page SEO meta overrides (optional)
|
||||||
|
|
||||||
|
CMS forms expose optional **SEO title** / **SEO description** on products, blogs, portfolios, videos, instructions, workshops, user-products, and categories.
|
||||||
|
|
||||||
|
Public detail payloads include:
|
||||||
|
|
||||||
|
- `seoMetaTitle` (`string | null`)
|
||||||
|
- `seoMetaDescription` (`string | null`)
|
||||||
|
|
||||||
|
Website rules:
|
||||||
|
|
||||||
|
1. Prefer `seoMetaTitle` for `<title>` when non-null; else use the normal title/`nameFa`.
|
||||||
|
2. Prefer `seoMetaDescription` for `<meta name="description">` when non-null; else summary/abstract/about.
|
||||||
|
3. Product/blog `schema.jsonLd` already prefers these overrides when set.
|
||||||
|
4. Empty/null means “no override” — never show placeholder text as meta.
|
||||||
|
|
||||||
|
**Checklist before shipping a page**
|
||||||
|
1. Unique `<title>` and meta description (use `seoMeta*` when present)
|
||||||
|
2. One clear H1 + sensible H2 sections
|
||||||
|
3. All content images have alt text
|
||||||
|
4. Public URL is included via CMS sitemap and/or `sitemap-config.json` static pages
|
||||||
|
5. Site-wide Organization/LocalBusiness JSON-LD from `business-info.schema.jsonLd` when enabled
|
||||||
|
6. Product/blog detail pages emit `product.schema.jsonLd` / `blog.schema.jsonLd` when present
|
||||||
|
7. Site-wide `metaTags` + footer `trustBadges` from business-info when present
|
||||||
|
|
||||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
@@ -121,4 +424,4 @@ If OpenAPI and this brief conflict, **OpenAPI wins**.
|
|||||||
|
|
||||||
## What to tell each website team
|
## 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": {
|
"info": {
|
||||||
"name": "Meshkee Website API (Global)",
|
"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"
|
"schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
|
||||||
},
|
},
|
||||||
"variable": [
|
"variable": [
|
||||||
@@ -715,6 +715,11 @@
|
|||||||
"key": "title",
|
"key": "title",
|
||||||
"value": "",
|
"value": "",
|
||||||
"disabled": true
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tag",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -822,6 +827,11 @@
|
|||||||
"key": "title",
|
"key": "title",
|
||||||
"value": "",
|
"value": "",
|
||||||
"disabled": true
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tag",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -896,6 +906,7 @@
|
|||||||
],
|
],
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Published portfolios (newest / highest sortOrder first). Items include nullable `projectUrl` (external website link).",
|
||||||
"url": {
|
"url": {
|
||||||
"raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12",
|
"raw": "{{baseUrl}}/tenants/{{domain}}/portfolios?page=1&pageSize=12",
|
||||||
"host": [
|
"host": [
|
||||||
@@ -924,6 +935,11 @@
|
|||||||
"key": "title",
|
"key": "title",
|
||||||
"value": "",
|
"value": "",
|
||||||
"disabled": true
|
"disabled": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "tag",
|
||||||
|
"value": "",
|
||||||
|
"disabled": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -933,9 +949,18 @@
|
|||||||
"name": "Get published portfolio by slug (website)",
|
"name": "Get published portfolio by slug (website)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Portfolio detail. Includes nullable `projectUrl` (external website link).",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/portfolios/{{portfolioSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get published portfolio by id (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Preferred for /portfolio/{slug} pages. Includes nullable `projectUrl`.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/portfolios/by-id/{{portfolioId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "List portfolio comments (website)",
|
"name": "List portfolio comments (website)",
|
||||||
"request": {
|
"request": {
|
||||||
@@ -1212,6 +1237,7 @@
|
|||||||
"name": "Get product technical info by slug",
|
"name": "Get product technical info by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Field labels + values for the product specs UI. Join form.fields[].id to values[].fieldId. Detail alone has no labels.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info"
|
"url": "{{baseUrl}}/tenants/{{domain}}/products/{{productSlug}}/technical-info"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1328,10 +1354,27 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Preferred for /user-products/{id}/{pathSlug} pages. technicalValues are values only — use technical-info for specs UI.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Get user product technical info by id",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"description": "Required for technical-details UI when page is resolved by id: form.fields (labels) + values.",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/by-id/{{userProductId}}/technical-info"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "Get user product by slug",
|
"name": "Get user product by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Legacy slug resolve. Prefer by-id for storefront pages. technicalValues are values only (fieldId + text/option) — no labels.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -1339,6 +1382,7 @@
|
|||||||
"name": "Get user product technical info by slug",
|
"name": "Get user product technical info by slug",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
|
"description": "Legacy specs by slug. Prefer by-id technical-info. Join field.id ↔ value.fieldId.",
|
||||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1459,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",
|
"name": "Homepage",
|
||||||
"item": [
|
"item": [
|
||||||
@@ -1469,6 +1544,13 @@
|
|||||||
"url": "{{baseUrl}}/tenants/{{domain}}/website/business-info"
|
"url": "{{baseUrl}}/tenants/{{domain}}/website/business-info"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "Get favicon + logos (website)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"url": "{{baseUrl}}/tenants/{{domain}}/website/favicon"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "List category groups (website)",
|
"name": "List category groups (website)",
|
||||||
"request": {
|
"request": {
|
||||||
@@ -1680,7 +1762,7 @@
|
|||||||
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"returnUrl\": \"https://YOUR_WEBSITE_DOMAIN/checkout/result\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"mellat\"\n }\n}"
|
"raw": "{\n \"addressId\": \"{{addressId}}\",\n \"customerNotes\": \"Deliver after 5pm\",\n \"returnUrl\": \"https://YOUR_WEBSITE_DOMAIN/checkout/result\",\n \"payment\": {\n \"type\": \"e_payment_gate\",\n \"gatewayType\": \"mellat\"\n }\n}"
|
||||||
},
|
},
|
||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout",
|
"url": "{{baseUrl}}/businesses/{{businessId}}/cart/checkout",
|
||||||
"description": "Creates a pending order + transaction, calls the gateway, and returns payment.redirect { method, url, fields }. Website must POST/redirect the shopper to the bank. After payment, bank hits /payments/{gateway}/callback which redirects to returnUrl?status=success|failed."
|
"description": "Creates a pending order + transaction, calls the gateway, and returns payment.redirect { method, url, fields }. Website must POST/redirect the shopper to the bank. After payment, the bank hits https://YOUR_WEBSITE_DOMAIN/meshkee/payments/{gateway}/callback (nginx → API), which redirects to returnUrl?status=success|failed."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -1698,7 +1780,64 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Mellat callback (bank → API)",
|
"name": "Mellat callback via store domain (gateway → nginx → API)",
|
||||||
|
"request": {
|
||||||
|
"method": "POST",
|
||||||
|
"header": [
|
||||||
|
{
|
||||||
|
"key": "Content-Type",
|
||||||
|
"value": "application/x-www-form-urlencoded"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"body": {
|
||||||
|
"mode": "urlencoded",
|
||||||
|
"urlencoded": [
|
||||||
|
{
|
||||||
|
"key": "ResCode",
|
||||||
|
"value": "0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleOrderId",
|
||||||
|
"value": "{{transactionId}}"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "SaleReferenceId",
|
||||||
|
"value": "123456789"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "RefId",
|
||||||
|
"value": "{{refId}}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"url": "https://YOUR_WEBSITE_DOMAIN/meshkee/payments/mellat/callback",
|
||||||
|
"description": "Preferred path: bank posts to the store apex; nginx proxies to /tenants/{host}/payments/mellat/callback. Verifies+settles, then 303 redirect to returnUrl."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "ZarinPal callback via store domain (gateway → nginx → API)",
|
||||||
|
"request": {
|
||||||
|
"method": "GET",
|
||||||
|
"header": [],
|
||||||
|
"url": {
|
||||||
|
"raw": "https://YOUR_WEBSITE_DOMAIN/meshkee/payments/zarinpal/callback?Authority={{authority}}&Status=OK",
|
||||||
|
"host": ["YOUR_WEBSITE_DOMAIN"],
|
||||||
|
"path": [
|
||||||
|
"meshkee",
|
||||||
|
"payments",
|
||||||
|
"zarinpal",
|
||||||
|
"callback"
|
||||||
|
],
|
||||||
|
"query": [
|
||||||
|
{ "key": "Authority", "value": "{{authority}}" },
|
||||||
|
{ "key": "Status", "value": "OK" }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"description": "Preferred path: ZarinPal redirects to the store apex; nginx proxies to /tenants/{host}/payments/zarinpal/callback. Verifies payment, then 303 redirect to returnUrl."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Mellat callback (legacy API URL)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "POST",
|
"method": "POST",
|
||||||
"header": [
|
"header": [
|
||||||
@@ -1729,11 +1868,11 @@
|
|||||||
]
|
]
|
||||||
},
|
},
|
||||||
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
"url": "{{baseUrl}}/businesses/{{businessId}}/payments/mellat/callback",
|
||||||
"description": "Called by Mellat (not by the website). Verifies+settles, then 303 redirect to returnUrl."
|
"description": "Legacy direct API callback. Prefer store-domain /meshkee/payments/... so gateway domain checks match the merchant terminal."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "ZarinPal callback (gateway → API)",
|
"name": "ZarinPal callback (legacy API URL)",
|
||||||
"request": {
|
"request": {
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"header": [],
|
"header": [],
|
||||||
@@ -1752,7 +1891,7 @@
|
|||||||
{ "key": "Status", "value": "OK" }
|
{ "key": "Status", "value": "OK" }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"description": "Called by ZarinPal (not by the website). Verifies payment, then 303 redirect to returnUrl."
|
"description": "Legacy direct API callback. Prefer store-domain /meshkee/payments/... so ZarinPal domain checks match the merchant terminal."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ X-Api-Key: <partner-secret>
|
|||||||
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
| `domain` | yes | Allowlisted partner apex, e.g. `baloutpastry.com` (`www.` is stripped) |
|
||||||
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
| `to` | yes | Mobile: `09…`, `9…`, `+989…`, or `989…` |
|
||||||
| `message` | yes | Free text, max 700 characters |
|
| `message` | yes | Free text, max 700 characters |
|
||||||
|
| `source` | no | Gama shortcode override (8–16 digits). Default: advertising `5000990009` |
|
||||||
|
|
||||||
### Example (Balout)
|
### Example (Balout)
|
||||||
|
|
||||||
@@ -72,7 +73,8 @@ First allowlisted partner: **baloutpastry.com**.
|
|||||||
|
|
||||||
## Sender line (v1)
|
## 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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -89,15 +89,85 @@
|
|||||||
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
|
<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><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>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>
|
</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 <accessToken></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
|
||||||
|
<code>faviconUrl</code>, <code>logoUrl</code>, <code>logoDarkUrl</code>, and
|
||||||
|
<code>hasDedicatedFavicon</code>. Use <code>faviconUrl</code> for the browser tab icon
|
||||||
|
(Next.js <code>metadata.icons</code>). When no dedicated favicon is uploaded,
|
||||||
|
<code>faviconUrl</code> falls back to <code>logoUrl</code>.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>User products (customer listings)</h2>
|
<h2>User products (customer listings)</h2>
|
||||||
<p>
|
<p>
|
||||||
Marketplace-style stock listings created by customers. Public read-only under
|
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>.
|
See OpenAPI tag <strong>User Products</strong>.
|
||||||
</p>
|
</p>
|
||||||
|
<p>
|
||||||
|
<strong>Technical details:</strong> detail responses include
|
||||||
|
<code>technicalValues</code> with <em>values only</em> (no field labels).
|
||||||
|
For a label→value specs table, call
|
||||||
|
<code>GET /tenants/{domain}/user-products/by-id/{id}/technical-info</code>
|
||||||
|
(catalog products:
|
||||||
|
<code>.../products/by-id/{id}/technical-info</code>) and join
|
||||||
|
<code>form.fields[].id</code> ↔ <code>values[].fieldId</code>.
|
||||||
|
There is no public <code>product-category-variation-fields</code> route.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h2>Torob (price comparison)</h2>
|
||||||
|
<p>
|
||||||
|
<code>POST /tenants/{domain}/torob_api/v3/products</code> is for
|
||||||
|
<strong>Torob</strong>, not storefront JavaScript. On the live shop,
|
||||||
|
nginx proxies <code>POST https://{domain}/torob_api/v3/products</code>
|
||||||
|
to that API. It only returns catalog store items when the business has
|
||||||
|
the <strong>store</strong> module enabled <em>and</em> Store settings →
|
||||||
|
Torob is on; otherwise 404.
|
||||||
|
Do not implement this path in Next.js.
|
||||||
|
</p>
|
||||||
|
|
||||||
<h2>For a new website AI / designer</h2>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
|
|||||||
+972
-24
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -9,7 +9,7 @@ module.exports = {
|
|||||||
env: {
|
env: {
|
||||||
NODE_ENV: 'production',
|
NODE_ENV: 'production',
|
||||||
},
|
},
|
||||||
max_memory_restart: '512M',
|
max_memory_restart: '2G',
|
||||||
time: true,
|
time: true,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ model User {
|
|||||||
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
||||||
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
|
titleId BigInt? @map("title_id")
|
||||||
|
title UserTitle? @relation(fields: [titleId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
@@ -48,11 +50,49 @@ model User {
|
|||||||
transactions Transaction[] @relation("TransactionCustomer")
|
transactions Transaction[] @relation("TransactionCustomer")
|
||||||
userProducts UserProduct[]
|
userProducts UserProduct[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
|
smsTemplatesVerified SmsTemplate[] @relation("SmsTemplateVerifier")
|
||||||
|
rollCalls RollCall[]
|
||||||
|
|
||||||
@@index([cellNumber], map: "idx_users_cell_number")
|
@@index([cellNumber], map: "idx_users_cell_number")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model UserTitle {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
slug String @unique(map: "user_titles_slug_unique") @db.VarChar(32)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(64)
|
||||||
|
nameFa String @map("name_fa") @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
users User[]
|
||||||
|
|
||||||
|
@@index([sortOrder, id], map: "idx_user_titles_sort")
|
||||||
|
@@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 {
|
model Business {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
@@ -69,16 +109,29 @@ model Business {
|
|||||||
phoneNumbers Json @default("[]") @map("phone_numbers")
|
phoneNumbers Json @default("[]") @map("phone_numbers")
|
||||||
socialMedia Json @default("{}") @map("social_media")
|
socialMedia Json @default("{}") @map("social_media")
|
||||||
logoMediaId BigInt? @map("logo_media_id")
|
logoMediaId BigInt? @map("logo_media_id")
|
||||||
|
logoDarkMediaId BigInt? @map("logo_dark_media_id")
|
||||||
faviconMediaId BigInt? @map("favicon_media_id")
|
faviconMediaId BigInt? @map("favicon_media_id")
|
||||||
|
bannerMediaId BigInt? @map("banner_media_id")
|
||||||
oldBusinessId BigInt? @map("old_business_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[]
|
addresses Address[]
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brands Brand[]
|
brands Brand[]
|
||||||
categoryAssignments BusinessCategoryAssignment[]
|
categoryAssignments BusinessCategoryAssignment[]
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
businessUsers BusinessUser[]
|
businessUsers BusinessUser[]
|
||||||
|
smsTemplates SmsTemplate[]
|
||||||
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction)
|
||||||
logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], 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[]
|
carts Cart[]
|
||||||
categories Category[]
|
categories Category[]
|
||||||
contentCategoryAssignments CategoryAssignment[]
|
contentCategoryAssignments CategoryAssignment[]
|
||||||
@@ -87,9 +140,11 @@ model Business {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
contactSubmissions ContactSubmission[]
|
contactSubmissions ContactSubmission[]
|
||||||
domains Domain[]
|
domains Domain[]
|
||||||
|
entityTranslations EntityTranslation[]
|
||||||
expertReviews ExpertReview[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
invoiceItemTemplates InvoiceItemTemplate[]
|
invoiceItemTemplates InvoiceItemTemplate[]
|
||||||
|
invoiceAccountTemplates InvoiceAccountTemplate[]
|
||||||
invoiceTemplates InvoiceTemplate[]
|
invoiceTemplates InvoiceTemplate[]
|
||||||
invoices Invoice[] @relation("InvoiceBusiness")
|
invoices Invoice[] @relation("InvoiceBusiness")
|
||||||
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness")
|
||||||
@@ -106,6 +161,8 @@ model Business {
|
|||||||
shoppingCards ShoppingCard[]
|
shoppingCards ShoppingCard[]
|
||||||
storeItemVariants StoreItemVariant[]
|
storeItemVariants StoreItemVariant[]
|
||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
|
storeLoanMethods StoreLoanMethod[]
|
||||||
|
shippingCostGroups ShippingCostGroup[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||||
@@ -114,6 +171,9 @@ model Business {
|
|||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_image_slots website_image_slots[]
|
website_image_slots website_image_slots[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
|
websitePageViews WebsitePageView[]
|
||||||
|
websitePageViewCounters WebsitePageViewCounter[]
|
||||||
|
rollCalls RollCall[]
|
||||||
|
|
||||||
@@map("businesses")
|
@@map("businesses")
|
||||||
}
|
}
|
||||||
@@ -122,11 +182,13 @@ model BusinessCategory {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
parentId BigInt? @map("parent_id")
|
parentId BigInt? @map("parent_id")
|
||||||
name String @db.VarChar(255)
|
name String @db.VarChar(255)
|
||||||
|
nameFa String? @map("name_fa") @db.VarChar(255)
|
||||||
slug String @unique(map: "business_categories_slug_unique") @db.VarChar(255)
|
slug String @unique(map: "business_categories_slug_unique") @db.VarChar(255)
|
||||||
description String?
|
description String?
|
||||||
icon String? @db.VarChar(100)
|
icon String? @db.VarChar(100)
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
tags String[] @default([])
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
parent BusinessCategory? @relation("BusinessCategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
parent BusinessCategory? @relation("BusinessCategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
||||||
@@ -158,6 +220,7 @@ model Domain {
|
|||||||
isVerified Boolean @default(false) @map("is_verified")
|
isVerified Boolean @default(false) @map("is_verified")
|
||||||
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
verifiedAt DateTime? @map("verified_at") @db.Timestamptz(6)
|
||||||
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
sslEnabled Boolean @default(false) @map("ssl_enabled")
|
||||||
|
sslExpiresAt DateTime? @map("ssl_expires_at") @db.Timestamptz(6)
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
expiresAt DateTime? @map("expires_at") @db.Timestamptz(6)
|
||||||
@@ -166,7 +229,11 @@ model Domain {
|
|||||||
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
lastDeployStatus String? @map("last_deploy_status") @db.VarChar(32)
|
||||||
deploySlug String? @map("deploy_slug") @db.VarChar(64)
|
deploySlug String? @map("deploy_slug") @db.VarChar(64)
|
||||||
gitRepoUrl String? @map("git_repo_url") @db.VarChar(512)
|
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)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
aiPrompts DomainAiPrompt[]
|
||||||
|
|
||||||
@@index([businessId], map: "idx_domains_business_id")
|
@@index([businessId], map: "idx_domains_business_id")
|
||||||
@@index([host], map: "idx_domains_host")
|
@@index([host], map: "idx_domains_host")
|
||||||
@@ -174,6 +241,36 @@ model Domain {
|
|||||||
@@map("domains")
|
@@map("domains")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model AiPromptTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
body String
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
domainPrompts DomainAiPrompt[]
|
||||||
|
|
||||||
|
@@index([createdAt(sort: Desc)], map: "idx_ai_prompt_templates_created_at")
|
||||||
|
@@map("ai_prompt_templates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model DomainAiPrompt {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
domainId BigInt @map("domain_id")
|
||||||
|
sourceTemplateId BigInt? @map("source_template_id")
|
||||||
|
name String @db.VarChar(255)
|
||||||
|
body String
|
||||||
|
publicId String @unique(map: "domain_ai_prompts_public_id_unique") @map("public_id") @db.VarChar(32)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
domain Domain @relation(fields: [domainId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
sourceTemplate AiPromptTemplate? @relation(fields: [sourceTemplateId], references: [id], onDelete: SetNull, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([domainId, sortOrder, id], map: "idx_domain_ai_prompts_domain_id")
|
||||||
|
@@index([sourceTemplateId], map: "idx_domain_ai_prompts_source_template_id")
|
||||||
|
@@map("domain_ai_prompts")
|
||||||
|
}
|
||||||
|
|
||||||
model BusinessUser {
|
model BusinessUser {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
@@ -286,8 +383,11 @@ model Media {
|
|||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
blogs blogs[]
|
blogs blogs[]
|
||||||
brandImages Brand[]
|
brandImages Brand[]
|
||||||
|
storeLoanMethodImages StoreLoanMethod[]
|
||||||
faviconBusinesses Business[] @relation("BusinessFavicon")
|
faviconBusinesses Business[] @relation("BusinessFavicon")
|
||||||
logoBusinesses Business[] @relation("BusinessLogo")
|
logoBusinesses Business[] @relation("BusinessLogo")
|
||||||
|
logoDarkBusinesses Business[] @relation("BusinessLogoDark")
|
||||||
|
bannerBusinesses Business[] @relation("BusinessBanner")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
uploader User? @relation(fields: [uploadedBy], references: [id], onUpdate: NoAction)
|
||||||
attachments MediaAttachment[]
|
attachments MediaAttachment[]
|
||||||
@@ -296,6 +396,7 @@ model Media {
|
|||||||
instructions instructions[]
|
instructions instructions[]
|
||||||
workshops workshops[]
|
workshops workshops[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
|
catalogProducts Product[] @relation("ProductCatalogMedia")
|
||||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_image_slot_items website_image_slot_items[]
|
website_image_slot_items website_image_slot_items[]
|
||||||
website_slider_slides website_slider_slides[]
|
website_slider_slides website_slider_slides[]
|
||||||
@@ -338,12 +439,14 @@ model Category {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
nameFa String? @map("name_fa") @db.VarChar(255)
|
nameFa String? @map("name_fa") @db.VarChar(255)
|
||||||
oldId BigInt? @map("old_id")
|
oldId BigInt? @map("old_id")
|
||||||
|
metadata Json @default("{}")
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
parent Category? @relation("CategoryTree", fields: [parentId], references: [id], onUpdate: NoAction)
|
||||||
children Category[] @relation("CategoryTree")
|
children Category[] @relation("CategoryTree")
|
||||||
assignments CategoryAssignment[]
|
assignments CategoryAssignment[]
|
||||||
technicalForm CategoryTechnicalForm?
|
technicalForm CategoryTechnicalForm?
|
||||||
variations CategoryVariation[]
|
variations CategoryVariation[]
|
||||||
|
shippingCostGroupCategories ShippingCostGroupCategory[]
|
||||||
website_category_group_items website_category_group_items[]
|
website_category_group_items website_category_group_items[]
|
||||||
|
|
||||||
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
@@unique([businessId, entityType, slug], map: "categories_business_entity_slug_unique")
|
||||||
@@ -426,12 +529,16 @@ model Product {
|
|||||||
stockQuantity Int? @map("stock_quantity")
|
stockQuantity Int? @map("stock_quantity")
|
||||||
status ContentStatus @default(draft)
|
status ContentStatus @default(draft)
|
||||||
featuredMediaId BigInt? @map("featured_media_id")
|
featuredMediaId BigInt? @map("featured_media_id")
|
||||||
|
catalogMediaId BigInt? @map("catalog_media_id")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
||||||
metadata Json @default("{}")
|
metadata Json @default("{}")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
brandId BigInt? @map("brand_id")
|
brandId BigInt? @map("brand_id")
|
||||||
|
visitCount BigInt @default(0) @map("visit_count")
|
||||||
|
cartAddCount BigInt @default(0) @map("cart_add_count")
|
||||||
|
saleCount BigInt @default(0) @map("sale_count")
|
||||||
expertReviews ExpertReview[]
|
expertReviews ExpertReview[]
|
||||||
favorites Favorite[]
|
favorites Favorite[]
|
||||||
orderItems OrderItem[]
|
orderItems OrderItem[]
|
||||||
@@ -440,6 +547,7 @@ model Product {
|
|||||||
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
featuredMedia Media? @relation("ProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
catalogMedia Media? @relation("ProductCatalogMedia", fields: [catalogMediaId], references: [id], onUpdate: NoAction)
|
||||||
shoppingCardItems ShoppingCardItem[]
|
shoppingCardItems ShoppingCardItem[]
|
||||||
storeItem StoreItem[]
|
storeItem StoreItem[]
|
||||||
|
|
||||||
@@ -448,6 +556,7 @@ model Product {
|
|||||||
@@index([brandId], map: "idx_products_brand_id")
|
@@index([brandId], map: "idx_products_brand_id")
|
||||||
@@index([businessId, status], map: "idx_products_business_status")
|
@@index([businessId, status], map: "idx_products_business_status")
|
||||||
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
@@index([businessId, publishedAt(sort: Desc)], map: "idx_products_business_published")
|
||||||
|
@@index([catalogMediaId], map: "idx_products_catalog_media_id")
|
||||||
@@map("products")
|
@@map("products")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,6 +947,7 @@ model City {
|
|||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
children City[] @relation("CityTree")
|
children City[] @relation("CityTree")
|
||||||
|
shippingCostCityRates ShippingCostGroupCityRate[]
|
||||||
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||||
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||||
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||||
@@ -1043,6 +1153,7 @@ model StoreItem {
|
|||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
productId BigInt @map("product_id")
|
productId BigInt @map("product_id")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isUsed Boolean @default(false) @map("is_used")
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
@@ -1054,6 +1165,7 @@ model StoreItem {
|
|||||||
@@unique([businessId, productId], map: "store_items_business_product_unique")
|
@@unique([businessId, productId], map: "store_items_business_product_unique")
|
||||||
@@index([businessId], map: "idx_store_items_business_id")
|
@@index([businessId], map: "idx_store_items_business_id")
|
||||||
@@index([productId], map: "idx_store_items_product_id")
|
@@index([productId], map: "idx_store_items_product_id")
|
||||||
|
@@index([businessId, isUsed], map: "idx_store_items_business_is_used")
|
||||||
@@map("store_items")
|
@@map("store_items")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1119,6 +1231,84 @@ model StoreSpecial {
|
|||||||
@@map("store_specials")
|
@@map("store_specials")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model StoreLoanMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
nameFa String @map("name") @db.VarChar(255)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
imageMediaId BigInt? @map("image_media_id")
|
||||||
|
imageAspectRatio String @default("1:1") @map("image_aspect_ratio") @db.VarChar(10)
|
||||||
|
minShoppingAmount Decimal @map("min_shopping_amount") @db.Decimal(12, 2)
|
||||||
|
minAmount Decimal @map("min_amount") @db.Decimal(12, 2)
|
||||||
|
maxAmount Decimal @map("max_amount") @db.Decimal(12, 2)
|
||||||
|
interest Decimal @db.Decimal(8, 4)
|
||||||
|
returnMonthsMin Int @map("return_months_min")
|
||||||
|
returnMonthsMax Int @map("return_months_max")
|
||||||
|
returnMonthsStep Int @default(3) @map("return_months_step")
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_store_loan_methods_business_id")
|
||||||
|
@@index([imageMediaId], map: "idx_store_loan_methods_image_media_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_store_loan_methods_business_sort_order")
|
||||||
|
@@map("store_loan_methods")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroup {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
nameFa String @map("name") @db.VarChar(255)
|
||||||
|
nameEn String @map("name_en") @db.VarChar(255)
|
||||||
|
description String?
|
||||||
|
methods String[] @default([])
|
||||||
|
defaultAmount Decimal @default(0) @map("default_amount") @db.Decimal(12, 2)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
pasKraye Boolean @default(false) @map("pas_kraye")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
cityRates ShippingCostGroupCityRate[]
|
||||||
|
categories ShippingCostGroupCategory[]
|
||||||
|
|
||||||
|
@@index([businessId], map: "idx_shipping_cost_groups_business_id")
|
||||||
|
@@index([businessId, sortOrder], map: "idx_shipping_cost_groups_business_sort_order")
|
||||||
|
@@map("shipping_cost_groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCityRate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
cityId BigInt @map("city_id")
|
||||||
|
amount Decimal @db.Decimal(12, 2)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
city City @relation(fields: [cityId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([shippingCostGroupId, cityId], map: "shipping_cost_group_city_rates_unique")
|
||||||
|
@@index([shippingCostGroupId], map: "idx_shipping_cost_group_city_rates_group_id")
|
||||||
|
@@index([cityId], map: "idx_shipping_cost_group_city_rates_city_id")
|
||||||
|
@@map("shipping_cost_group_city_rates")
|
||||||
|
}
|
||||||
|
|
||||||
|
model ShippingCostGroupCategory {
|
||||||
|
shippingCostGroupId BigInt @map("shipping_cost_group_id")
|
||||||
|
categoryId BigInt @map("category_id")
|
||||||
|
shippingCostGroup ShippingCostGroup @relation(fields: [shippingCostGroupId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
category Category @relation(fields: [categoryId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@id([shippingCostGroupId, categoryId])
|
||||||
|
@@index([categoryId], map: "idx_shipping_cost_group_categories_category_id")
|
||||||
|
@@map("shipping_cost_group_categories")
|
||||||
|
}
|
||||||
|
|
||||||
model website_brand_group_items {
|
model website_brand_group_items {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
group_id BigInt
|
group_id BigInt
|
||||||
@@ -1270,6 +1460,25 @@ model InvoiceItemTemplate {
|
|||||||
@@map("invoice_item_templates")
|
@@map("invoice_item_templates")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoiceAccountTemplate {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
|
businessId BigInt? @map("business_id")
|
||||||
|
bankName String @map("bank_name") @db.VarChar(255)
|
||||||
|
accountHolderName String? @map("account_holder_name") @db.VarChar(255)
|
||||||
|
cardNumber String? @map("card_number") @db.VarChar(64)
|
||||||
|
iban String? @db.VarChar(64)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
isActive Boolean @default(true) @map("is_active")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([ownerScope, sortOrder], map: "idx_invoice_account_templates_owner_scope")
|
||||||
|
@@index([businessId], map: "idx_invoice_account_templates_business_id")
|
||||||
|
@@map("invoice_account_templates")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceTemplate {
|
model InvoiceTemplate {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
ownerScope InvoiceOwnerScope @map("owner_scope")
|
ownerScope InvoiceOwnerScope @map("owner_scope")
|
||||||
@@ -1283,6 +1492,7 @@ model InvoiceTemplate {
|
|||||||
accounts InvoiceTemplateAccount[]
|
accounts InvoiceTemplateAccount[]
|
||||||
items InvoiceTemplateItem[]
|
items InvoiceTemplateItem[]
|
||||||
keyPoints InvoiceTemplateKeyPoint[]
|
keyPoints InvoiceTemplateKeyPoint[]
|
||||||
|
paymentMethods InvoiceTemplatePaymentMethod[]
|
||||||
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business? @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
invoices Invoice[]
|
invoices Invoice[]
|
||||||
|
|
||||||
@@ -1323,6 +1533,19 @@ model InvoiceTemplateKeyPoint {
|
|||||||
@@map("invoice_template_key_points")
|
@@map("invoice_template_key_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoiceTemplatePaymentMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
templateId BigInt @map("template_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
template InvoiceTemplate @relation(fields: [templateId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([templateId, sortOrder], map: "idx_invoice_template_payment_methods_template_id")
|
||||||
|
@@map("invoice_template_payment_methods")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceTemplateAccount {
|
model InvoiceTemplateAccount {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
templateId BigInt @map("template_id")
|
templateId BigInt @map("template_id")
|
||||||
@@ -1358,6 +1581,7 @@ model Invoice {
|
|||||||
accounts InvoiceAccount[]
|
accounts InvoiceAccount[]
|
||||||
items InvoiceItem[]
|
items InvoiceItem[]
|
||||||
keyPoints InvoiceKeyPoint[]
|
keyPoints InvoiceKeyPoint[]
|
||||||
|
paymentMethods InvoicePaymentMethod[]
|
||||||
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation("InvoiceBusiness", fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
invoiceTemplate InvoiceTemplate? @relation(fields: [invoiceTemplateId], references: [id], onUpdate: NoAction)
|
||||||
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
issuer User? @relation("InvoiceIssuer", fields: [issuedBy], references: [id], onUpdate: NoAction)
|
||||||
@@ -1404,6 +1628,19 @@ model InvoiceKeyPoint {
|
|||||||
@@map("invoice_key_points")
|
@@map("invoice_key_points")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model InvoicePaymentMethod {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
invoiceId BigInt @map("invoice_id")
|
||||||
|
text String
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
invoice Invoice @relation(fields: [invoiceId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([invoiceId, sortOrder], map: "idx_invoice_payment_methods_invoice_id")
|
||||||
|
@@map("invoice_payment_methods")
|
||||||
|
}
|
||||||
|
|
||||||
model InvoiceAccount {
|
model InvoiceAccount {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
invoiceId BigInt @map("invoice_id")
|
invoiceId BigInt @map("invoice_id")
|
||||||
@@ -1505,6 +1742,7 @@ model UserProduct {
|
|||||||
enum MediaType {
|
enum MediaType {
|
||||||
image
|
image
|
||||||
video
|
video
|
||||||
|
document
|
||||||
|
|
||||||
@@map("media_type")
|
@@map("media_type")
|
||||||
}
|
}
|
||||||
@@ -1518,6 +1756,7 @@ enum MediaEntityType {
|
|||||||
video
|
video
|
||||||
instruction
|
instruction
|
||||||
workshop
|
workshop
|
||||||
|
gallery
|
||||||
|
|
||||||
@@map("media_entity_type")
|
@@map("media_entity_type")
|
||||||
}
|
}
|
||||||
@@ -1559,6 +1798,7 @@ enum TechnicalFieldType {
|
|||||||
textarea
|
textarea
|
||||||
select
|
select
|
||||||
multi_select
|
multi_select
|
||||||
|
multi_value
|
||||||
|
|
||||||
@@map("technical_field_type")
|
@@map("technical_field_type")
|
||||||
}
|
}
|
||||||
@@ -1633,3 +1873,126 @@ enum UserProductCondition {
|
|||||||
|
|
||||||
@@map("user_product_condition")
|
@@map("user_product_condition")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum WebsitePageKind {
|
||||||
|
home
|
||||||
|
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
|
||||||
|
static_page
|
||||||
|
|
||||||
|
@@map("website_page_kind")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Raw page-view events (retained ~6 months; purged by cron).
|
||||||
|
model WebsitePageView {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
pageKind WebsitePageKind @map("page_kind")
|
||||||
|
entityId BigInt @default(0) @map("entity_id")
|
||||||
|
pagePath String? @map("page_path")
|
||||||
|
viewedAt DateTime @default(now()) @map("viewed_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([businessId, pageKind, viewedAt(sort: Desc)], map: "idx_website_page_views_business_kind_time")
|
||||||
|
@@map("website_page_views")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lifetime totals per page kind (+ entity for detail pages). Never purged.
|
||||||
|
model WebsitePageViewCounter {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
pageKind WebsitePageKind @map("page_kind")
|
||||||
|
entityId BigInt @default(0) @map("entity_id")
|
||||||
|
totalCount BigInt @default(0) @map("total_count")
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([businessId, pageKind, entityId], map: "uq_website_page_view_counters_biz_kind_entity")
|
||||||
|
@@index([businessId, pageKind], map: "idx_website_page_view_counters_business_kind")
|
||||||
|
@@map("website_page_view_counters")
|
||||||
|
}
|
||||||
|
|
||||||
|
model EntityTranslation {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
entityType TranslationEntityType @map("entity_type")
|
||||||
|
entityId BigInt @map("entity_id")
|
||||||
|
locale TranslationLocale
|
||||||
|
fields Json @default("{}")
|
||||||
|
sourceHash String @map("source_hash") @db.VarChar(64)
|
||||||
|
status TranslationStatus @default(pending)
|
||||||
|
generatedBy String @default("ai") @map("generated_by") @db.VarChar(16)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@unique([entityType, entityId, locale], map: "entity_translations_entity_locale_unique")
|
||||||
|
@@index([businessId], map: "idx_entity_translations_business_id")
|
||||||
|
@@index([entityType, entityId], map: "idx_entity_translations_entity")
|
||||||
|
@@map("entity_translations")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranslationEntityType {
|
||||||
|
product
|
||||||
|
category
|
||||||
|
|
||||||
|
@@map("translation_entity_type")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranslationLocale {
|
||||||
|
en
|
||||||
|
ar
|
||||||
|
|
||||||
|
@@map("translation_locale")
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TranslationStatus {
|
||||||
|
pending
|
||||||
|
ready
|
||||||
|
stale
|
||||||
|
failed
|
||||||
|
|
||||||
|
@@map("translation_status")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RollCall {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
businessId BigInt @map("business_id")
|
||||||
|
userId BigInt @map("user_id")
|
||||||
|
workDate DateTime @map("work_date") @db.Date
|
||||||
|
startTime String @map("start_time") @db.VarChar(5)
|
||||||
|
endTime String @map("end_time") @db.VarChar(5)
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
tasks RollCallTask[]
|
||||||
|
|
||||||
|
@@unique([businessId, userId, workDate], map: "roll_calls_business_user_date_unique")
|
||||||
|
@@index([businessId], map: "idx_roll_calls_business_id")
|
||||||
|
@@index([userId], map: "idx_roll_calls_user_id")
|
||||||
|
@@index([businessId, userId, workDate(sort: Desc)], map: "idx_roll_calls_business_user_date")
|
||||||
|
@@map("roll_calls")
|
||||||
|
}
|
||||||
|
|
||||||
|
model RollCallTask {
|
||||||
|
id BigInt @id @default(autoincrement())
|
||||||
|
rollCallId BigInt @map("roll_call_id")
|
||||||
|
title String @db.VarChar(500)
|
||||||
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||||
|
rollCall RollCall @relation(fields: [rollCallId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
|
@@index([rollCallId], map: "idx_roll_call_tasks_roll_call_id")
|
||||||
|
@@map("roll_call_tasks")
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,10 +7,14 @@
|
|||||||
# GET /deploy-status?slug=… — last deploy status JSON for slug
|
# GET /deploy-status?slug=… — last deploy status JSON for slug
|
||||||
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot)
|
# POST /provision { slug, host, gitRepoUrl } — clone + nginx + ecosystem + allowlist (no certbot)
|
||||||
# POST /ssl { host, slug? } — certbot for apex + www (nginx must exist)
|
# 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
|
# GET /health
|
||||||
#
|
#
|
||||||
# Scripts on the VM (same folder):
|
# 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-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
|
# Env (.env): PORT, DEPLOY_TOKEN, ALLOWED_SLUGS
|
||||||
#
|
#
|
||||||
@@ -19,3 +23,15 @@
|
|||||||
# deploy.sh checks out origin/HEAD (falls back to main, then master).
|
# deploy.sh checks out origin/HEAD (falls back to main, then master).
|
||||||
# If the site builds with Next `output: 'standalone'`, deploy copies static/public
|
# 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`.
|
# 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."
|
||||||
|
|||||||
@@ -28,12 +28,45 @@ write_status() {
|
|||||||
>"$STATUS"
|
>"$STATUS"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fail_snippet() {
|
||||||
|
if [[ ! -f "$LOG" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local chunk snippet=""
|
||||||
|
# Only the current deploy (log is appended across runs).
|
||||||
|
chunk="$(awk 'BEGIN{s=""} /^==== / && /deploy start:/{s=""} {s=s $0 ORS} END{printf "%s", s}' "$LOG")"
|
||||||
|
snippet="$(
|
||||||
|
{ printf '%s' "$chunk" | grep -E "Error:|Type error|Module not found|ELIFECYCLE|npm ERR!" || true; } \
|
||||||
|
| sed 's/\x1b\[[0-9;]*m//g' \
|
||||||
|
| tail -n 4 \
|
||||||
|
| tr '\n' ' ' \
|
||||||
|
| sed 's/[[:space:]]\+/ /g'
|
||||||
|
)"
|
||||||
|
if [[ -z "${snippet// }" ]]; then
|
||||||
|
snippet="$(
|
||||||
|
printf '%s' "$chunk" \
|
||||||
|
| tail -n 12 \
|
||||||
|
| sed 's/\x1b\[[0-9;]*m//g' \
|
||||||
|
| tr '\n' ' ' \
|
||||||
|
| sed 's/[[:space:]]\+/ /g'
|
||||||
|
)"
|
||||||
|
fi
|
||||||
|
printf '%s' "$snippet"
|
||||||
|
}
|
||||||
|
|
||||||
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ===="
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy start: $SLUG ===="
|
||||||
write_status "started" "Deploy running"
|
write_status "started" "Deploy running"
|
||||||
|
|
||||||
cleanup_fail() {
|
cleanup_fail() {
|
||||||
local code=$?
|
local code=$?
|
||||||
|
trap - ERR
|
||||||
|
local snippet
|
||||||
|
snippet="$(fail_snippet)"
|
||||||
|
if [[ -n "${snippet// }" ]]; then
|
||||||
|
write_status "failed" "exit $code: $snippet"
|
||||||
|
else
|
||||||
write_status "failed" "deploy.sh exited with code $code"
|
write_status "failed" "deploy.sh exited with code $code"
|
||||||
|
fi
|
||||||
exit "$code"
|
exit "$code"
|
||||||
}
|
}
|
||||||
trap cleanup_fail ERR
|
trap cleanup_fail ERR
|
||||||
@@ -179,10 +212,120 @@ NODE
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Vinext (Vite production server) or legacy `next start`.
|
||||||
|
# Detect Vinext from package.json / CLI — do not assume every Next-looking
|
||||||
|
# app has a `.next` production build (vinext writes `dist/` instead).
|
||||||
|
uses_vinext=false
|
||||||
|
if node -e '
|
||||||
|
const fs = require("fs");
|
||||||
|
const p = JSON.parse(fs.readFileSync("package.json", "utf8"));
|
||||||
|
const d = Object.assign({}, p.dependencies || {}, p.devDependencies || {});
|
||||||
|
const s = String((p.scripts || {}).start || "");
|
||||||
|
const ok = Boolean(d.vinext) || s.includes("vinext") || fs.existsSync("node_modules/vinext/dist/cli.js");
|
||||||
|
process.exit(ok ? 0 : 1);
|
||||||
|
'; then
|
||||||
|
uses_vinext=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if $uses_vinext; then
|
||||||
|
if [[ ! -d dist ]]; then
|
||||||
|
echo "vinext project but dist/ is missing — build did not produce a production bundle"
|
||||||
|
write_status "failed" "vinext build produced no dist/"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "no Next standalone output — using vinext start"
|
||||||
|
elif [[ -f .next/BUILD_ID ]]; then
|
||||||
echo "no standalone output — using next start (legacy)"
|
echo "no standalone output — using next start (legacy)"
|
||||||
pm2 restart "$SLUG" --update-env || pm2 start "$ECOSYSTEM" --only "$SLUG"
|
else
|
||||||
|
echo "build produced neither .next/standalone, Vinext dist/, nor .next/BUILD_ID"
|
||||||
|
write_status "failed" "no production server output (need Next standalone, BUILD_ID, or vinext dist/)"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Reset cwd/script in case a previous deploy used standalone. Do not
|
||||||
|
# `pm2 restart --update-env`: the deploy agent has PORT=9050 and that
|
||||||
|
# leaks into the site, so nginx (site port) gets 502.
|
||||||
|
VINEXT="$uses_vinext" SLUG="$SLUG" ROOT="$ROOT" ECOSYSTEM="$ECOSYSTEM" node <<'NODE'
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = process.env.ECOSYSTEM;
|
||||||
|
const slug = process.env.SLUG;
|
||||||
|
const root = process.env.ROOT;
|
||||||
|
const usesVinext = process.env.VINEXT === 'true';
|
||||||
|
let cfg = { apps: [] };
|
||||||
|
try {
|
||||||
|
delete require.cache[require.resolve(path)];
|
||||||
|
cfg = require(path);
|
||||||
|
if (!Array.isArray(cfg.apps)) cfg.apps = [];
|
||||||
|
} catch (_) {
|
||||||
|
cfg = { apps: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
const app = cfg.apps.find((a) => a.name === slug);
|
||||||
|
if (!app) {
|
||||||
|
console.error('ecosystem entry missing for', slug);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromEnv = Number(app.env && app.env.PORT);
|
||||||
|
const fromArgs = Number((String(app.args || '').match(/--port\s+(\d+)/) || [])[1]);
|
||||||
|
const port =
|
||||||
|
[fromEnv, fromArgs].find((p) => Number.isFinite(p) && p > 0 && p !== 9050) || 3005;
|
||||||
|
|
||||||
|
app.cwd = root;
|
||||||
|
app.script = usesVinext
|
||||||
|
? 'node_modules/vinext/dist/cli.js'
|
||||||
|
: 'node_modules/next/dist/bin/next';
|
||||||
|
app.args = 'start --hostname 127.0.0.1 --port ' + port;
|
||||||
|
app.env = {
|
||||||
|
NODE_ENV: 'production',
|
||||||
|
PORT: String(port),
|
||||||
|
};
|
||||||
|
|
||||||
|
const lines = ['module.exports = {', ' apps: ['];
|
||||||
|
cfg.apps.forEach((entry, idx) => {
|
||||||
|
lines.push(' {');
|
||||||
|
lines.push(` name: ${JSON.stringify(entry.name)},`);
|
||||||
|
lines.push(` cwd: ${JSON.stringify(entry.cwd)},`);
|
||||||
|
lines.push(` script: ${JSON.stringify(entry.script)},`);
|
||||||
|
if (entry.args) {
|
||||||
|
lines.push(` args: ${JSON.stringify(entry.args)},`);
|
||||||
|
}
|
||||||
|
lines.push(' env: {');
|
||||||
|
for (const [key, value] of Object.entries(entry.env || {})) {
|
||||||
|
lines.push(` ${key}: ${JSON.stringify(String(value))},`);
|
||||||
|
}
|
||||||
|
lines.push(' },');
|
||||||
|
lines.push(` error_file: ${JSON.stringify(entry.error_file)},`);
|
||||||
|
lines.push(` out_file: ${JSON.stringify(entry.out_file)},`);
|
||||||
|
lines.push(' time: true,');
|
||||||
|
if (entry.max_memory_restart) {
|
||||||
|
lines.push(` max_memory_restart: ${JSON.stringify(entry.max_memory_restart)},`);
|
||||||
|
}
|
||||||
|
lines.push(idx === cfg.apps.length - 1 ? ' }' : ' },');
|
||||||
|
});
|
||||||
|
lines.push(' ],');
|
||||||
|
lines.push('};');
|
||||||
|
lines.push('');
|
||||||
|
fs.writeFileSync(path, lines.join('\n'));
|
||||||
|
console.log(
|
||||||
|
'ecosystem updated for',
|
||||||
|
usesVinext ? 'vinext start' : 'next start',
|
||||||
|
':',
|
||||||
|
slug,
|
||||||
|
'port',
|
||||||
|
port,
|
||||||
|
);
|
||||||
|
NODE
|
||||||
|
|
||||||
|
pm2 delete "$SLUG" || true
|
||||||
|
pm2 start "$ECOSYSTEM" --only "$SLUG"
|
||||||
pm2 save || true
|
pm2 save || true
|
||||||
|
|
||||||
|
if $uses_vinext; then
|
||||||
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok (vinext): $SLUG ===="
|
||||||
|
write_status "success" "Deployed origin/$BRANCH (vinext)"
|
||||||
|
else
|
||||||
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
|
echo "==== $(date -u +%Y-%m-%dT%H:%M:%SZ) deploy ok: $SLUG ===="
|
||||||
write_status "success" "Deployed origin/$BRANCH"
|
write_status "success" "Deployed origin/$BRANCH"
|
||||||
|
fi
|
||||||
trap - ERR
|
trap - ERR
|
||||||
|
|||||||
Executable
+83
@@ -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)"
|
||||||
Executable
+68
@@ -0,0 +1,68 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Insert / update Meshkee payment-callback proxy locations on a storefront nginx site.
|
||||||
|
# Bank gateways (ZarinPal/Mellat) hit https://{host}/meshkee/payments/{gateway}/callback
|
||||||
|
# which proxies to the API tenant callback route.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
HOST="${1:-}"
|
||||||
|
API_HOST="${MESHKEE_API_HOST:-api.meshkee.com}"
|
||||||
|
|
||||||
|
if [[ -z "$HOST" ]]; then
|
||||||
|
echo "usage: patch-nginx-pay.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
|
||||||
|
|
||||||
|
proxy_location() {
|
||||||
|
local gateway="$1"
|
||||||
|
local path="/meshkee/payments/${gateway}/callback"
|
||||||
|
cat <<NGINX
|
||||||
|
location = ${path} {
|
||||||
|
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/payments/${gateway}/callback;
|
||||||
|
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_pass_request_body on;
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
}
|
||||||
|
|
||||||
|
NGINX
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_location() {
|
||||||
|
local gateway="$1"
|
||||||
|
local path="/meshkee/payments/${gateway}/callback"
|
||||||
|
if grep -qF "location = ${path}" "$NGINX_AVAILABLE"; then
|
||||||
|
echo "already present: ${path}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
local block
|
||||||
|
block="$(proxy_location "$gateway")"
|
||||||
|
local tmp
|
||||||
|
tmp="$(mktemp)"
|
||||||
|
awk -v block="$block" '
|
||||||
|
/location \/ \{/ && !done {
|
||||||
|
print block
|
||||||
|
done = 1
|
||||||
|
}
|
||||||
|
{ print }
|
||||||
|
' "$NGINX_AVAILABLE" >"$tmp"
|
||||||
|
mv "$tmp" "$NGINX_AVAILABLE"
|
||||||
|
echo "added: ${path}"
|
||||||
|
}
|
||||||
|
|
||||||
|
ensure_location zarinpal
|
||||||
|
ensure_location mellat
|
||||||
|
|
||||||
|
nginx -t
|
||||||
|
systemctl reload nginx
|
||||||
|
echo "patched nginx payment callback locations for $HOST"
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Insert Meshkee SEO proxy locations (/sitemap.xml, /robots.txt) into an existing storefront nginx site.
|
# Insert / update Meshkee SEO proxy locations on an existing storefront nginx site.
|
||||||
|
# Covers HTTP + HTTPS server blocks: sitemap index, child sitemaps, robots.txt
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
HOST="${1:-}"
|
HOST="${1:-}"
|
||||||
@@ -17,45 +18,96 @@ if [[ ! -f "$NGINX_AVAILABLE" ]]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -q 'location = /sitemap.xml' "$NGINX_AVAILABLE"; then
|
python3 - "$NGINX_AVAILABLE" "$HOST" "$API_HOST" <<'PY'
|
||||||
echo "nginx SEO locations already present for $HOST"
|
import sys
|
||||||
exit 0
|
from pathlib import Path
|
||||||
fi
|
|
||||||
|
|
||||||
TMP="$(mktemp)"
|
path = Path(sys.argv[1])
|
||||||
SEO_BLOCK="$(cat <<NGINX
|
host = sys.argv[2]
|
||||||
location = /sitemap.xml {
|
api = sys.argv[3]
|
||||||
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/sitemap.xml;
|
text = path.read_text()
|
||||||
proxy_set_header Host ${API_HOST};
|
|
||||||
proxy_ssl_server_name on;
|
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
|
||||||
add_header Cache-Control "public, max-age=900";
|
|
||||||
}
|
|
||||||
|
|
||||||
location = /robots.txt {
|
paths = [
|
||||||
proxy_pass https://${API_HOST}/api/v1/tenants/${HOST}/robots.txt;
|
("/sitemap.xml", "sitemap.xml"),
|
||||||
proxy_set_header Host ${API_HOST};
|
("/sitemap-main.xml", "sitemap-main.xml"),
|
||||||
proxy_ssl_server_name on;
|
("/sitemap-products.xml", "sitemap-products.xml"),
|
||||||
proxy_set_header X-Real-IP \$remote_addr;
|
("/sitemap-blogs.xml", "sitemap-blogs.xml"),
|
||||||
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
("/sitemap-portfolios.xml", "sitemap-portfolios.xml"),
|
||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
("/sitemap-videos.xml", "sitemap-videos.xml"),
|
||||||
add_header Cache-Control "public, max-age=900";
|
("/sitemap-instructions.xml", "sitemap-instructions.xml"),
|
||||||
}
|
("/sitemap-workshops.xml", "sitemap-workshops.xml"),
|
||||||
|
("/sitemap-user-products.xml", "sitemap-user-products.xml"),
|
||||||
|
("/robots.txt", "robots.txt"),
|
||||||
|
]
|
||||||
|
|
||||||
NGINX
|
def block(loc: str, upstream: str) -> str:
|
||||||
)"
|
return (
|
||||||
|
f" location = {loc} {{\n"
|
||||||
|
f" proxy_pass https://{api}/api/v1/tenants/{host}/{upstream};\n"
|
||||||
|
f" proxy_set_header Host {api};\n"
|
||||||
|
f" proxy_ssl_server_name on;\n"
|
||||||
|
f" proxy_set_header X-Real-IP $remote_addr;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n"
|
||||||
|
f" proxy_set_header X-Forwarded-Proto $scheme;\n"
|
||||||
|
f" add_header Cache-Control \"public, max-age=900\";\n"
|
||||||
|
f" }}\n\n"
|
||||||
|
)
|
||||||
|
|
||||||
awk -v block="$SEO_BLOCK" '
|
parts: list[tuple[str, str]] = []
|
||||||
/location \/ \{/ && !done {
|
idx = 0
|
||||||
print block
|
while True:
|
||||||
done = 1
|
start = text.find("server {", idx)
|
||||||
}
|
if start < 0:
|
||||||
{ print }
|
parts.append(("tail", text[idx:]))
|
||||||
' "$NGINX_AVAILABLE" >"$TMP"
|
break
|
||||||
|
if start > idx:
|
||||||
|
parts.append(("other", text[idx:start]))
|
||||||
|
i = start + len("server {")
|
||||||
|
depth = 1
|
||||||
|
while i < len(text) and depth:
|
||||||
|
if text[i] == "{":
|
||||||
|
depth += 1
|
||||||
|
elif text[i] == "}":
|
||||||
|
depth -= 1
|
||||||
|
i += 1
|
||||||
|
parts.append(("server", text[start:i]))
|
||||||
|
idx = i
|
||||||
|
|
||||||
|
changed = False
|
||||||
|
out: list[str] = []
|
||||||
|
for kind, chunk in parts:
|
||||||
|
if kind != "server":
|
||||||
|
out.append(chunk)
|
||||||
|
continue
|
||||||
|
|
||||||
|
server = chunk
|
||||||
|
if host not in server:
|
||||||
|
out.append(server)
|
||||||
|
continue
|
||||||
|
|
||||||
|
insert_at = server.find(" location / {")
|
||||||
|
if insert_at < 0:
|
||||||
|
insert_at = server.find("\tlocation / {")
|
||||||
|
if insert_at < 0:
|
||||||
|
out.append(server)
|
||||||
|
continue
|
||||||
|
|
||||||
|
additions = ""
|
||||||
|
for loc, upstream in paths:
|
||||||
|
if f"location = {loc}" not in server:
|
||||||
|
additions += block(loc, upstream)
|
||||||
|
changed = True
|
||||||
|
if additions:
|
||||||
|
server = server[:insert_at] + additions + server[insert_at:]
|
||||||
|
out.append(server)
|
||||||
|
|
||||||
|
if changed:
|
||||||
|
path.write_text("".join(out))
|
||||||
|
print(f"updated: {host}")
|
||||||
|
else:
|
||||||
|
print(f"already present: {host}")
|
||||||
|
PY
|
||||||
|
|
||||||
mv "$TMP" "$NGINX_AVAILABLE"
|
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
echo "patched nginx SEO locations for $HOST"
|
echo "patched nginx SEO locations for $HOST"
|
||||||
|
|||||||
Executable
+58
@@ -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"
|
||||||
@@ -115,13 +115,21 @@ NODE
|
|||||||
|
|
||||||
echo "using port $PORT for $SLUG"
|
echo "using port $PORT for $SLUG"
|
||||||
|
|
||||||
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
|
# Origin TLS so Cloudflare Full / direct HTTPS hit this vhost (not another site's default_server).
|
||||||
cat >"$NGINX_AVAILABLE" <<NGINX
|
# Certbot / Cloudflare Origin CA can replace these files later.
|
||||||
server {
|
SSL_DIR="/etc/nginx/ssl/$HOST"
|
||||||
listen 80;
|
mkdir -p "$SSL_DIR"
|
||||||
listen [::]:80;
|
if [[ ! -f "$SSL_DIR/fullchain.pem" || ! -f "$SSL_DIR/privkey.pem" ]]; then
|
||||||
server_name ${HOST} www.${HOST};
|
openssl req -x509 -nodes -newkey rsa:2048 -days 825 \
|
||||||
|
-keyout "$SSL_DIR/privkey.pem" \
|
||||||
|
-out "$SSL_DIR/fullchain.pem" \
|
||||||
|
-subj "/CN=$HOST" \
|
||||||
|
-addext "subjectAltName=DNS:$HOST,DNS:www.$HOST" \
|
||||||
|
>/dev/null 2>&1
|
||||||
|
echo "origin self-signed cert created: $SSL_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
PROXY_COMMON=$(cat <<PROXY
|
||||||
location = /sitemap.xml {
|
location = /sitemap.xml {
|
||||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap.xml;
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap.xml;
|
||||||
proxy_set_header Host api.meshkee.com;
|
proxy_set_header Host api.meshkee.com;
|
||||||
@@ -132,6 +140,86 @@ server {
|
|||||||
add_header Cache-Control "public, max-age=900";
|
add_header Cache-Control "public, max-age=900";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /sitemap-main.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-main.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-products.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-products.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-blogs.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-blogs.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-portfolios.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-portfolios.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-videos.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-videos.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-instructions.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-instructions.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-workshops.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-workshops.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /sitemap-user-products.xml {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/sitemap-user-products.xml;
|
||||||
|
proxy_set_header Host api.meshkee.com;
|
||||||
|
proxy_ssl_server_name on;
|
||||||
|
proxy_set_header X-Real-IP \$remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
|
add_header Cache-Control "public, max-age=900";
|
||||||
|
}
|
||||||
|
|
||||||
location = /robots.txt {
|
location = /robots.txt {
|
||||||
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/robots.txt;
|
||||||
proxy_set_header Host api.meshkee.com;
|
proxy_set_header Host api.meshkee.com;
|
||||||
@@ -142,6 +230,41 @@ server {
|
|||||||
add_header Cache-Control "public, max-age=900";
|
add_header Cache-Control "public, max-age=900";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
location = /meshkee/payments/zarinpal/callback {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/payments/zarinpal/callback;
|
||||||
|
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_pass_request_body on;
|
||||||
|
proxy_pass_request_headers on;
|
||||||
|
}
|
||||||
|
|
||||||
|
location = /meshkee/payments/mellat/callback {
|
||||||
|
proxy_pass https://api.meshkee.com/api/v1/tenants/${HOST}/payments/mellat/callback;
|
||||||
|
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_pass_request_body on;
|
||||||
|
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 / {
|
location / {
|
||||||
proxy_pass http://127.0.0.1:${PORT};
|
proxy_pass http://127.0.0.1:${PORT};
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
@@ -153,18 +276,46 @@ server {
|
|||||||
proxy_set_header X-Forwarded-Proto \$scheme;
|
proxy_set_header X-Forwarded-Proto \$scheme;
|
||||||
proxy_cache_bypass \$http_upgrade;
|
proxy_cache_bypass \$http_upgrade;
|
||||||
}
|
}
|
||||||
|
PROXY
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ ! -f "$NGINX_AVAILABLE" ]]; then
|
||||||
|
cat >"$NGINX_AVAILABLE" <<NGINX
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
listen [::]:80;
|
||||||
|
server_name ${HOST} www.${HOST};
|
||||||
|
|
||||||
|
${PROXY_COMMON}
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
listen [::]:443 ssl;
|
||||||
|
server_name ${HOST} www.${HOST};
|
||||||
|
|
||||||
|
ssl_certificate ${SSL_DIR}/fullchain.pem;
|
||||||
|
ssl_certificate_key ${SSL_DIR}/privkey.pem;
|
||||||
|
|
||||||
|
${PROXY_COMMON}
|
||||||
}
|
}
|
||||||
NGINX
|
NGINX
|
||||||
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
|
ln -sfn "$NGINX_AVAILABLE" "$NGINX_ENABLED"
|
||||||
nginx -t
|
nginx -t
|
||||||
systemctl reload nginx
|
systemctl reload nginx
|
||||||
echo "nginx site created for $HOST → :$PORT"
|
echo "nginx site created for $HOST → :$PORT (http+https)"
|
||||||
else
|
else
|
||||||
echo "nginx site already exists: $NGINX_AVAILABLE"
|
echo "nginx site already exists: $NGINX_AVAILABLE"
|
||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
if [[ -x "$SCRIPT_DIR/patch-nginx-seo.sh" ]]; then
|
if [[ -x "$SCRIPT_DIR/patch-nginx-seo.sh" ]]; then
|
||||||
"$SCRIPT_DIR/patch-nginx-seo.sh" "$HOST" || true
|
"$SCRIPT_DIR/patch-nginx-seo.sh" "$HOST" || true
|
||||||
fi
|
fi
|
||||||
|
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
|
fi
|
||||||
|
|
||||||
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
|
# SSL is issued separately via ssl.sh / Super Admin "Issue SSL" — do not run
|
||||||
|
|||||||
@@ -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' });
|
return send(res, 404, { error: 'not found' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Executable
+31
@@ -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"
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Header,
|
||||||
|
HttpCode,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { AiPromptsService } from './ai-prompts.service';
|
||||||
|
import { CreateAiPromptTemplateDto } from './dto/create-ai-prompt-template.dto';
|
||||||
|
import { CreateDomainAiPromptDto } from './dto/create-domain-ai-prompt.dto';
|
||||||
|
import { UpdateAiPromptTemplateDto } from './dto/update-ai-prompt-template.dto';
|
||||||
|
import { UpdateDomainAiPromptDto } from './dto/update-domain-ai-prompt.dto';
|
||||||
|
|
||||||
|
@Controller()
|
||||||
|
export class AiPromptsController {
|
||||||
|
constructor(private readonly service: AiPromptsService) {}
|
||||||
|
|
||||||
|
@Get('ai-prompt-templates')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
list(@CurrentUser() user: AuthUser) {
|
||||||
|
return this.service.list(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('ai-prompt-templates')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
create(
|
||||||
|
@Body() dto: CreateAiPromptTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.create(dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('ai-prompt-templates/:templateId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
update(
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@Body() dto: UpdateAiPromptTemplateDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.update(templateId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('ai-prompt-templates/:templateId')
|
||||||
|
@HttpCode(200)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
remove(
|
||||||
|
@Param('templateId') templateId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.remove(templateId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('domains/:domainId/ai-prompts')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
listDomainPrompts(
|
||||||
|
@Param('domainId') domainId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.listDomainPrompts(domainId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('domains/:domainId/ai-prompts')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
createDomainPrompt(
|
||||||
|
@Param('domainId') domainId: string,
|
||||||
|
@Body() dto: CreateDomainAiPromptDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.createDomainPrompt(domainId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('domains/:domainId/ai-prompts/:promptId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
updateDomainPrompt(
|
||||||
|
@Param('domainId') domainId: string,
|
||||||
|
@Param('promptId') promptId: string,
|
||||||
|
@Body() dto: UpdateDomainAiPromptDto,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.updateDomainPrompt(domainId, promptId, dto, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('domains/:domainId/ai-prompts/:promptId')
|
||||||
|
@HttpCode(200)
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
removeDomainPrompt(
|
||||||
|
@Param('domainId') domainId: string,
|
||||||
|
@Param('promptId') promptId: string,
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
) {
|
||||||
|
return this.service.removeDomainPrompt(domainId, promptId, user);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('public/ai-prompts/:host/:name/:prompt/:publicId')
|
||||||
|
@Header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
getPublicLegacyHost(@Param('publicId') publicId: string) {
|
||||||
|
return this.service.getPublicText(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('public/ai-prompts/:name/:prompt/:publicId')
|
||||||
|
@Header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
getPublicLabeled(@Param('publicId') publicId: string) {
|
||||||
|
return this.service.getPublicText(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('public/ai-prompts/:name/:publicId')
|
||||||
|
@Header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
getPublicNamed(@Param('publicId') publicId: string) {
|
||||||
|
return this.service.getPublicText(publicId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('public/ai-prompts/:publicId')
|
||||||
|
@Header('Content-Type', 'text/plain; charset=utf-8')
|
||||||
|
getPublic(@Param('publicId') publicId: string) {
|
||||||
|
return this.service.getPublicText(publicId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { AiPromptsController } from './ai-prompts.controller';
|
||||||
|
import { AiPromptsService } from './ai-prompts.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [AuthModule],
|
||||||
|
controllers: [AiPromptsController],
|
||||||
|
providers: [AiPromptsService],
|
||||||
|
})
|
||||||
|
export class AiPromptsModule {}
|
||||||
@@ -0,0 +1,380 @@
|
|||||||
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { ConfigService } from '@nestjs/config';
|
||||||
|
import { randomBytes } from 'crypto';
|
||||||
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { CreateAiPromptTemplateDto } from './dto/create-ai-prompt-template.dto';
|
||||||
|
import { CreateDomainAiPromptDto } from './dto/create-domain-ai-prompt.dto';
|
||||||
|
import { UpdateAiPromptTemplateDto } from './dto/update-ai-prompt-template.dto';
|
||||||
|
import { UpdateDomainAiPromptDto } from './dto/update-domain-ai-prompt.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class AiPromptsService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly permissions: PermissionsService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private async assertSuperAdmin(actor: AuthUser) {
|
||||||
|
if (!(await this.permissions.isSuperAdmin(actor.id))) {
|
||||||
|
throw new ForbiddenException('Super admin access required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async generateUniquePromptPublicId(): Promise<string> {
|
||||||
|
for (let attempt = 0; attempt < 12; attempt += 1) {
|
||||||
|
const candidate = randomBytes(12).toString('hex');
|
||||||
|
const existing = await this.prisma.domainAiPrompt.findUnique({
|
||||||
|
where: { publicId: candidate },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return candidate;
|
||||||
|
}
|
||||||
|
throw new BadRequestException('Unable to allocate a public prompt id');
|
||||||
|
}
|
||||||
|
|
||||||
|
private slugSegment(value: string, fallback = 'site'): string {
|
||||||
|
const slug = value
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/['"]/g, '')
|
||||||
|
.replace(/[^a-z0-9\u0600-\u06ff]+/gi, '-')
|
||||||
|
.replace(/-+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
return slug || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
private publicUrl(
|
||||||
|
publicId: string,
|
||||||
|
labels?: {
|
||||||
|
name?: string | null;
|
||||||
|
promptName?: string | null;
|
||||||
|
},
|
||||||
|
): string {
|
||||||
|
const base =
|
||||||
|
this.config.get<string>('API_PUBLIC_BASE_URL')?.replace(/\/$/, '') ||
|
||||||
|
'https://api.meshkee.com';
|
||||||
|
const nameSlug = labels?.name?.trim()
|
||||||
|
? this.slugSegment(labels.name)
|
||||||
|
: '';
|
||||||
|
const promptSlug = labels?.promptName?.trim()
|
||||||
|
? this.slugSegment(labels.promptName)
|
||||||
|
: '';
|
||||||
|
const parts = [
|
||||||
|
nameSlug ? encodeURIComponent(nameSlug) : '',
|
||||||
|
promptSlug ? encodeURIComponent(promptSlug) : '',
|
||||||
|
publicId,
|
||||||
|
].filter(Boolean);
|
||||||
|
return `${base}/api/v1/public/ai-prompts/${parts.join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeTemplate(row: {
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
}) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
name: row.name,
|
||||||
|
body: row.body,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private serializeDomainPrompt(
|
||||||
|
row: {
|
||||||
|
id: bigint;
|
||||||
|
domainId: bigint;
|
||||||
|
sourceTemplateId: bigint | null;
|
||||||
|
name: string;
|
||||||
|
body: string;
|
||||||
|
publicId: string;
|
||||||
|
sortOrder: number;
|
||||||
|
createdAt: Date;
|
||||||
|
updatedAt: Date;
|
||||||
|
},
|
||||||
|
labels?: {
|
||||||
|
host?: string | null;
|
||||||
|
name?: string | null;
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
id: row.id.toString(),
|
||||||
|
domainId: row.domainId.toString(),
|
||||||
|
sourceTemplateId: row.sourceTemplateId?.toString() ?? null,
|
||||||
|
name: row.name,
|
||||||
|
body: row.body,
|
||||||
|
publicId: row.publicId,
|
||||||
|
publicUrl: this.publicUrl(row.publicId, {
|
||||||
|
name: labels?.name,
|
||||||
|
promptName: row.name,
|
||||||
|
}),
|
||||||
|
sortOrder: row.sortOrder,
|
||||||
|
createdAt: row.createdAt.toISOString(),
|
||||||
|
updatedAt: row.updatedAt.toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async domainLabels(domainId: bigint) {
|
||||||
|
const domain = await this.prisma.domain.findUnique({
|
||||||
|
where: { id: domainId },
|
||||||
|
select: {
|
||||||
|
host: true,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!domain) return undefined;
|
||||||
|
return { host: domain.host, name: domain.business.name };
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const items = await this.prisma.aiPromptTemplate.findMany({
|
||||||
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
|
});
|
||||||
|
return { items: items.map((row) => this.serializeTemplate(row)) };
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateAiPromptTemplateDto, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const created = await this.prisma.aiPromptTemplate.create({
|
||||||
|
data: {
|
||||||
|
name: dto.name.trim(),
|
||||||
|
body: dto.body.trim(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeTemplate(created);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
templateIdRaw: string,
|
||||||
|
dto: UpdateAiPromptTemplateDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const id = BigInt(templateIdRaw);
|
||||||
|
const existing = await this.prisma.aiPromptTemplate.findUnique({
|
||||||
|
where: { id },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Template not found');
|
||||||
|
|
||||||
|
const updated = await this.prisma.aiPromptTemplate.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeTemplate(updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(templateIdRaw: string, actor: AuthUser) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const id = BigInt(templateIdRaw);
|
||||||
|
try {
|
||||||
|
await this.prisma.aiPromptTemplate.delete({ where: { id } });
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException('Template not found');
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async listDomainPrompts(domainIdRaw: string, 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,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!domain) throw new NotFoundException('Website not found');
|
||||||
|
|
||||||
|
const items = await this.prisma.domainAiPrompt.findMany({
|
||||||
|
where: { domainId },
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
domainId: domain.id.toString(),
|
||||||
|
host: domain.host,
|
||||||
|
businessName: domain.business.name,
|
||||||
|
items: items.map((row) =>
|
||||||
|
this.serializeDomainPrompt(row, {
|
||||||
|
host: domain.host,
|
||||||
|
name: domain.business.name,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async createDomainPrompt(
|
||||||
|
domainIdRaw: string,
|
||||||
|
dto: CreateDomainAiPromptDto,
|
||||||
|
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,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!domain) throw new NotFoundException('Website not found');
|
||||||
|
|
||||||
|
let sourceTemplateId: bigint | null = null;
|
||||||
|
if (dto.sourceTemplateId !== undefined) {
|
||||||
|
sourceTemplateId = BigInt(dto.sourceTemplateId);
|
||||||
|
const template = await this.prisma.aiPromptTemplate.findUnique({
|
||||||
|
where: { id: sourceTemplateId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!template) throw new NotFoundException('Template not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxSort = await this.prisma.domainAiPrompt.aggregate({
|
||||||
|
where: { domainId },
|
||||||
|
_max: { sortOrder: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const created = await this.prisma.domainAiPrompt.create({
|
||||||
|
data: {
|
||||||
|
domainId,
|
||||||
|
sourceTemplateId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
body: dto.body.trim(),
|
||||||
|
publicId: await this.generateUniquePromptPublicId(),
|
||||||
|
sortOrder: (maxSort._max.sortOrder ?? -1) + 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeDomainPrompt(created, {
|
||||||
|
host: domain.host,
|
||||||
|
name: domain.business.name,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateDomainPrompt(
|
||||||
|
domainIdRaw: string,
|
||||||
|
promptIdRaw: string,
|
||||||
|
dto: UpdateDomainAiPromptDto,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const domainId = BigInt(domainIdRaw);
|
||||||
|
const id = BigInt(promptIdRaw);
|
||||||
|
const existing = await this.prisma.domainAiPrompt.findFirst({
|
||||||
|
where: { id, domainId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Prompt not found');
|
||||||
|
|
||||||
|
const updated = await this.prisma.domainAiPrompt.update({
|
||||||
|
where: { id },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.body !== undefined ? { body: dto.body.trim() } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return this.serializeDomainPrompt(
|
||||||
|
updated,
|
||||||
|
await this.domainLabels(domainId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeDomainPrompt(
|
||||||
|
domainIdRaw: string,
|
||||||
|
promptIdRaw: string,
|
||||||
|
actor: AuthUser,
|
||||||
|
) {
|
||||||
|
await this.assertSuperAdmin(actor);
|
||||||
|
const domainId = BigInt(domainIdRaw);
|
||||||
|
const id = BigInt(promptIdRaw);
|
||||||
|
const existing = await this.prisma.domainAiPrompt.findFirst({
|
||||||
|
where: { id, domainId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) throw new NotFoundException('Prompt not found');
|
||||||
|
await this.prisma.domainAiPrompt.delete({ where: { id } });
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublic(publicIdRaw: string) {
|
||||||
|
const publicId = publicIdRaw.trim();
|
||||||
|
if (!/^[a-f0-9]{16,32}$/i.test(publicId)) {
|
||||||
|
throw new NotFoundException('Prompt not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const prompt = await this.prisma.domainAiPrompt.findUnique({
|
||||||
|
where: { publicId },
|
||||||
|
include: {
|
||||||
|
domain: {
|
||||||
|
select: {
|
||||||
|
host: true,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (prompt) {
|
||||||
|
return {
|
||||||
|
name: prompt.name,
|
||||||
|
body: prompt.body,
|
||||||
|
website: prompt.domain.host,
|
||||||
|
businessName: prompt.domain.business.name,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backward compatible: old domain pack public ids.
|
||||||
|
const domain = await this.prisma.domain.findFirst({
|
||||||
|
where: { aiPromptsPublicId: publicId },
|
||||||
|
select: {
|
||||||
|
host: true,
|
||||||
|
business: { select: { name: true } },
|
||||||
|
aiPrompts: {
|
||||||
|
orderBy: [{ sortOrder: 'asc' }, { id: 'asc' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!domain) throw new NotFoundException('Prompt not found');
|
||||||
|
|
||||||
|
return {
|
||||||
|
website: domain.host,
|
||||||
|
businessName: domain.business.name,
|
||||||
|
templates: domain.aiPrompts.map((item) => ({
|
||||||
|
name: item.name,
|
||||||
|
body: item.body,
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicText(publicIdRaw: string): Promise<string> {
|
||||||
|
const data = await this.getPublic(publicIdRaw);
|
||||||
|
if ('body' in data && typeof data.body === 'string') {
|
||||||
|
return data.body;
|
||||||
|
}
|
||||||
|
const pack = data as {
|
||||||
|
templates: Array<{ name: string; body: string }>;
|
||||||
|
};
|
||||||
|
if (!pack.templates?.length) return '';
|
||||||
|
return pack.templates
|
||||||
|
.map((t) => `# ${t.name}\n\n${t.body}`)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateAiPromptTemplateDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
body!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
MaxLength,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateDomainAiPromptDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
body!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
sourceTemplateId?: number;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateAiPromptTemplateDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class UpdateDomainAiPromptDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
body?: string;
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { RedisModule } from './redis/redis.module';
|
|||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { BusinessTeamModule } from './business-team/business-team.module';
|
import { BusinessTeamModule } from './business-team/business-team.module';
|
||||||
import { BusinessAdminModule } from './business-admin/business-admin.module';
|
import { BusinessAdminModule } from './business-admin/business-admin.module';
|
||||||
|
import { UserTitlesModule } from './user-titles/user-titles.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
import { RolesModule } from './roles/roles.module';
|
import { RolesModule } from './roles/roles.module';
|
||||||
import { TenantModule } from './tenant/tenant.module';
|
import { TenantModule } from './tenant/tenant.module';
|
||||||
@@ -16,6 +17,7 @@ import { CategoriesModule } from './categories/categories.module';
|
|||||||
import { ProductsModule } from './products/products.module';
|
import { ProductsModule } from './products/products.module';
|
||||||
import { BlogsModule } from './blogs/blogs.module';
|
import { BlogsModule } from './blogs/blogs.module';
|
||||||
import { PortfoliosModule } from './portfolios/portfolios.module';
|
import { PortfoliosModule } from './portfolios/portfolios.module';
|
||||||
|
import { GalleryModule } from './gallery/gallery.module';
|
||||||
import { VideosModule } from './videos/videos.module';
|
import { VideosModule } from './videos/videos.module';
|
||||||
import { InstructionsModule } from './instructions/instructions.module';
|
import { InstructionsModule } from './instructions/instructions.module';
|
||||||
import { WorkshopsModule } from './workshops/workshops.module';
|
import { WorkshopsModule } from './workshops/workshops.module';
|
||||||
@@ -36,11 +38,16 @@ import { BrandsModule } from './brands/brands.module';
|
|||||||
import { WebsiteModule } from './website/website.module';
|
import { WebsiteModule } from './website/website.module';
|
||||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||||
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
import { WebsiteDocsModule } from './website-docs/website-docs.module';
|
||||||
|
import { WebsiteAnalyticsModule } from './website-analytics/website-analytics.module';
|
||||||
import { InvoicesModule } from './invoices/invoices.module';
|
import { InvoicesModule } from './invoices/invoices.module';
|
||||||
|
import { AiPromptsModule } from './ai-prompts/ai-prompts.module';
|
||||||
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
import { LegacyMysqlModule } from './legacy-mysql/legacy-mysql.module';
|
||||||
import { PublicSmsModule } from './public-sms/public-sms.module';
|
import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||||
import { PaymentsModule } from './payments/payments.module';
|
import { PaymentsModule } from './payments/payments.module';
|
||||||
import { SitemapModule } from './sitemap/sitemap.module';
|
import { SitemapModule } from './sitemap/sitemap.module';
|
||||||
|
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
||||||
|
import { TorobModule } from './torob/torob.module';
|
||||||
|
import { RollCallsModule } from './roll-calls/roll-calls.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -52,6 +59,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
AuthModule,
|
AuthModule,
|
||||||
BusinessTeamModule,
|
BusinessTeamModule,
|
||||||
BusinessAdminModule,
|
BusinessAdminModule,
|
||||||
|
UserTitlesModule,
|
||||||
UsersModule,
|
UsersModule,
|
||||||
RolesModule,
|
RolesModule,
|
||||||
TenantModule,
|
TenantModule,
|
||||||
@@ -63,6 +71,7 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
ProductsModule,
|
ProductsModule,
|
||||||
BlogsModule,
|
BlogsModule,
|
||||||
PortfoliosModule,
|
PortfoliosModule,
|
||||||
|
GalleryModule,
|
||||||
VideosModule,
|
VideosModule,
|
||||||
InstructionsModule,
|
InstructionsModule,
|
||||||
WorkshopsModule,
|
WorkshopsModule,
|
||||||
@@ -82,10 +91,15 @@ import { SitemapModule } from './sitemap/sitemap.module';
|
|||||||
UserProductsModule,
|
UserProductsModule,
|
||||||
BrandsModule,
|
BrandsModule,
|
||||||
WebsiteModule,
|
WebsiteModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
WebsiteDocsModule,
|
WebsiteDocsModule,
|
||||||
InvoicesModule,
|
InvoicesModule,
|
||||||
|
AiPromptsModule,
|
||||||
PublicSmsModule,
|
PublicSmsModule,
|
||||||
SitemapModule,
|
SitemapModule,
|
||||||
|
SmsTemplatesModule,
|
||||||
|
TorobModule,
|
||||||
|
RollCallsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppModule {}
|
export class AppModule {}
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
import { Injectable, Logger, ServiceUnavailableException } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
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 DEFAULT_BASE_URL = 'https://napi.arvancloud.ir/cdn/4.0';
|
||||||
const TTL = 120;
|
const TTL = 120;
|
||||||
|
|
||||||
type ArvanRecordType = 'aname' | 'cname';
|
type ArvanRecordType = 'aname' | 'cname' | 'a' | 'mx' | 'txt';
|
||||||
|
|
||||||
type DesiredRecord = {
|
type DesiredRecord = {
|
||||||
type: ArvanRecordType;
|
type: ArvanRecordType;
|
||||||
name: string;
|
name: string;
|
||||||
value: Record<string, unknown>;
|
/** A records use an array of `{ ip }`; other types use a single object. */
|
||||||
|
value: Record<string, unknown> | Array<Record<string, unknown>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DnsMutationResult = {
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
|
removed: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
type ArvanDnsRecord = {
|
type ArvanDnsRecord = {
|
||||||
@@ -18,7 +35,7 @@ type ArvanDnsRecord = {
|
|||||||
name: string;
|
name: string;
|
||||||
ttl?: number;
|
ttl?: number;
|
||||||
cloud?: boolean;
|
cloud?: boolean;
|
||||||
value?: Record<string, unknown> | null;
|
value?: Record<string, unknown> | Array<Record<string, unknown>> | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -35,6 +52,120 @@ export class ArvanDnsService {
|
|||||||
created: string[];
|
created: string[];
|
||||||
updated: string[];
|
updated: string[];
|
||||||
skipped: string[];
|
skipped: string[];
|
||||||
|
}> {
|
||||||
|
return this.ensureRecords(hostRaw, this.desiredTenantRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parked alias: apex ANAME + www CNAME only (no business/customer/api).
|
||||||
|
* Zone must already exist in Arvan.
|
||||||
|
*/
|
||||||
|
async ensureParkedRecords(hostRaw: string): Promise<{
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
|
}> {
|
||||||
|
return this.ensureRecords(hostRaw, this.desiredParkedRecords);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stalwart mail for a Meshkee website: mail A, MX, SPF, DKIM, autoconfig.
|
||||||
|
* DNS-only (not orange-cloud). Does not touch website ANAME/CNAME records.
|
||||||
|
*/
|
||||||
|
async ensureMailRecords(hostRaw: string, keys: MailDkimKey[]): Promise<DnsMutationResult> {
|
||||||
|
const host = hostRaw.trim().toLowerCase();
|
||||||
|
if (!host) {
|
||||||
|
throw new ServiceUnavailableException('Domain host is required for Arvan DNS');
|
||||||
|
}
|
||||||
|
const auth = this.authHeader();
|
||||||
|
if (!auth) {
|
||||||
|
throw new ServiceUnavailableException('Arvan DNS is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const zone = await this.request('GET', `/domains/${encodeURIComponent(host)}`, auth);
|
||||||
|
if (zone.status === 404) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan zone "${host}" was not found. Add the domain in Arvan first.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (!zone.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan could not load zone "${host}" (${zone.status})${zone.message ? `: ${zone.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = await this.listRecords(host, auth);
|
||||||
|
const created: string[] = [];
|
||||||
|
const updated: string[] = [];
|
||||||
|
const skipped: string[] = [];
|
||||||
|
const removed: string[] = [];
|
||||||
|
|
||||||
|
await this.replaceConflicting(
|
||||||
|
host,
|
||||||
|
auth,
|
||||||
|
existing,
|
||||||
|
'mail',
|
||||||
|
['cname', 'aname'],
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'a',
|
||||||
|
name: 'mail',
|
||||||
|
// Arvan CDN 4.0 requires A `value` as an array of IP objects.
|
||||||
|
value: [{ ip: MAIL_IPV4 }],
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
await this.replaceExtraMx(host, auth, existing, removed);
|
||||||
|
await this.replaceConflicting(
|
||||||
|
host,
|
||||||
|
auth,
|
||||||
|
existing,
|
||||||
|
LEGACY_DKIM_NAME,
|
||||||
|
['txt', 'cname'],
|
||||||
|
removed,
|
||||||
|
);
|
||||||
|
await this.replaceConflicting(host, auth, existing, DMARC_NAME, ['txt', 'cname'], removed);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'mx',
|
||||||
|
name: '@',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, priority: MAIL_MX_PRIORITY },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
await this.upsertSpf(host, auth, existing, created, updated, skipped);
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'txt',
|
||||||
|
name: `${key.selector}._domainkey`,
|
||||||
|
value: { text: key.text },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'cname',
|
||||||
|
name: 'autoconfig',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
await this.upsertTyped(host, auth, existing, {
|
||||||
|
type: 'cname',
|
||||||
|
name: 'autodiscover',
|
||||||
|
value: { host: `${MAIL_HOSTNAME}.`, host_header: 'source' },
|
||||||
|
}, created, updated, skipped);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Arvan mail DNS ${host}: created=${created.join(',') || '-'} updated=${updated.join(',') || '-'} skipped=${skipped.join(',') || '-'} removed=${removed.join(',') || '-'}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return { created, updated, skipped, removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureRecords(
|
||||||
|
hostRaw: string,
|
||||||
|
desiredFor: (apex: string) => DesiredRecord[],
|
||||||
|
): Promise<{
|
||||||
|
created: string[];
|
||||||
|
updated: string[];
|
||||||
|
skipped: string[];
|
||||||
}> {
|
}> {
|
||||||
const host = hostRaw.trim().toLowerCase();
|
const host = hostRaw.trim().toLowerCase();
|
||||||
if (!host) {
|
if (!host) {
|
||||||
@@ -63,7 +194,7 @@ export class ArvanDnsService {
|
|||||||
const updated: string[] = [];
|
const updated: string[] = [];
|
||||||
const skipped: string[] = [];
|
const skipped: string[] = [];
|
||||||
|
|
||||||
for (const desired of this.desiredRecords(host)) {
|
for (const desired of desiredFor(host)) {
|
||||||
const match = existing.find(
|
const match = existing.find(
|
||||||
(item) => item.name === desired.name && item.type === desired.type,
|
(item) => item.name === desired.name && item.type === desired.type,
|
||||||
);
|
);
|
||||||
@@ -129,7 +260,7 @@ export class ArvanDnsService {
|
|||||||
return { created, updated, skipped };
|
return { created, updated, skipped };
|
||||||
}
|
}
|
||||||
|
|
||||||
private desiredRecords(apex: string): DesiredRecord[] {
|
private desiredTenantRecords(apex: string): DesiredRecord[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
type: 'aname',
|
type: 'aname',
|
||||||
@@ -159,12 +290,282 @@ export class ArvanDnsService {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
/** Apex + www only — parked aliases 301 to the canonical Meshkee domain. */
|
||||||
const current = existing.value ?? {};
|
private desiredParkedRecords(apex: string): DesiredRecord[] {
|
||||||
if (desired.type === 'aname') {
|
return [
|
||||||
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desired.value.location ?? ''));
|
{
|
||||||
|
type: 'aname',
|
||||||
|
name: '@',
|
||||||
|
value: { location: 'ns.meshkee.com.', host_header: 'source' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'cname',
|
||||||
|
name: 'www',
|
||||||
|
value: { host: `${apex}.`, host_header: 'source' },
|
||||||
|
},
|
||||||
|
];
|
||||||
}
|
}
|
||||||
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desired.value.host ?? ''));
|
|
||||||
|
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(this.recordValueObject(item.value))) === MAIL_HOSTNAME;
|
||||||
|
});
|
||||||
|
for (const item of mx) {
|
||||||
|
if (keep && item.id === keep.id) continue;
|
||||||
|
const del = await this.request(
|
||||||
|
'DELETE',
|
||||||
|
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
if (!del.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to remove extra MX (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = this.relativeName(item.name, host);
|
||||||
|
removed.push(`MX ${name} ${this.mxHost(this.recordValueObject(item.value)) || item.id}`);
|
||||||
|
const index = existing.indexOf(item);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async replaceConflicting(
|
||||||
|
host: string,
|
||||||
|
auth: string,
|
||||||
|
existing: ArvanDnsRecord[],
|
||||||
|
name: string,
|
||||||
|
types: string[],
|
||||||
|
removed: string[],
|
||||||
|
) {
|
||||||
|
const conflicts = existing.filter(
|
||||||
|
(item) =>
|
||||||
|
this.relativeName(item.name, host) === name && types.includes(item.type),
|
||||||
|
);
|
||||||
|
for (const item of conflicts) {
|
||||||
|
const del = await this.request(
|
||||||
|
'DELETE',
|
||||||
|
`/domains/${encodeURIComponent(host)}/dns-records/${item.id}`,
|
||||||
|
auth,
|
||||||
|
);
|
||||||
|
if (!del.ok) {
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
`Arvan failed to replace ${item.type} ${name} (${del.status})${del.message ? `: ${del.message}` : ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
removed.push(`${item.type.toUpperCase()} ${name}`);
|
||||||
|
const index = existing.indexOf(item);
|
||||||
|
if (index >= 0) existing.splice(index, 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sameTarget(desired: DesiredRecord, existing: ArvanDnsRecord): boolean {
|
||||||
|
const current = this.recordValueObject(existing.value);
|
||||||
|
const desiredObj = this.recordValueObject(desired.value);
|
||||||
|
if (desired.type === 'aname') {
|
||||||
|
return this.fqdn(String(current.location ?? '')) === this.fqdn(String(desiredObj.location ?? ''));
|
||||||
|
}
|
||||||
|
if (desired.type === 'a') {
|
||||||
|
return this.aIps(existing.value).join(',') === this.aIps(desired.value).join(',');
|
||||||
|
}
|
||||||
|
if (desired.type === 'mx') {
|
||||||
|
return (
|
||||||
|
this.fqdn(this.mxHost(current)) === this.fqdn(String(desiredObj.host ?? '')) &&
|
||||||
|
Number(current.priority ?? 0) === Number(desiredObj.priority ?? 0)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (desired.type === 'txt') {
|
||||||
|
return this.txtValue(existing).replace(/\s+/g, '') === String(desiredObj.text ?? '').replace(/\s+/g, '');
|
||||||
|
}
|
||||||
|
return this.fqdn(String(current.host ?? '')) === this.fqdn(String(desiredObj.host ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Arvan A records return `value` as `[{ ip }]`; normalize to IP list for compare/send. */
|
||||||
|
private aIps(value: DesiredRecord['value'] | ArvanDnsRecord['value']): string[] {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value
|
||||||
|
.map((item) => String(item?.ip ?? '').trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object' && 'ip' in value) {
|
||||||
|
const ip = String(value.ip ?? '').trim();
|
||||||
|
return ip ? [ip] : [];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordValueObject(
|
||||||
|
value: DesiredRecord['value'] | ArvanDnsRecord['value'],
|
||||||
|
): Record<string, unknown> {
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (value[0] as Record<string, unknown> | undefined) ?? {};
|
||||||
|
}
|
||||||
|
return value ?? {};
|
||||||
|
}
|
||||||
|
|
||||||
|
private txtValue(record: ArvanDnsRecord): string {
|
||||||
|
const value = this.recordValueObject(record.value);
|
||||||
|
if (typeof value.text === 'string') return value.text;
|
||||||
|
if (value.txt && typeof value.txt === 'object' && 'text' in value.txt) {
|
||||||
|
return String((value.txt as { text?: unknown }).text ?? '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private mxHost(value: Record<string, unknown>): string {
|
||||||
|
if (typeof value.host === 'string') return value.host;
|
||||||
|
if (value.mx && typeof value.mx === 'object' && value.mx && 'host' in value.mx) {
|
||||||
|
return String((value.mx as { host?: unknown }).host ?? '');
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
private relativeName(name: string, zone: string): string {
|
||||||
|
const n = name.replace(/\.$/, '').toLowerCase();
|
||||||
|
const z = zone.replace(/\.$/, '').toLowerCase();
|
||||||
|
if (n === z || n === '@') return '@';
|
||||||
|
if (n.endsWith(`.${z}`)) return n.slice(0, -(z.length + 1));
|
||||||
|
return n;
|
||||||
}
|
}
|
||||||
|
|
||||||
private fqdn(value: string): string {
|
private fqdn(value: string): string {
|
||||||
@@ -226,9 +627,33 @@ export class ArvanDnsService {
|
|||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
},
|
},
|
||||||
body: body === undefined ? undefined : JSON.stringify(body),
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
} catch {
|
} catch (err) {
|
||||||
throw new ServiceUnavailableException('Could not reach ArvanCloud DNS API');
|
const cause =
|
||||||
|
err && typeof err === 'object' && 'cause' in err
|
||||||
|
? (err as { cause?: unknown }).cause
|
||||||
|
: undefined;
|
||||||
|
const detail =
|
||||||
|
(cause &&
|
||||||
|
typeof cause === 'object' &&
|
||||||
|
'code' in cause &&
|
||||||
|
typeof (cause as { code: unknown }).code === 'string' &&
|
||||||
|
(cause as { code: string }).code) ||
|
||||||
|
(cause &&
|
||||||
|
typeof cause === 'object' &&
|
||||||
|
'message' in cause &&
|
||||||
|
typeof (cause as { message: unknown }).message === 'string' &&
|
||||||
|
(cause as { message: string }).message) ||
|
||||||
|
(err instanceof Error ? err.message : null);
|
||||||
|
this.logger.warn(
|
||||||
|
`Arvan DNS request failed ${method} ${path}${detail ? `: ${detail}` : ''}`,
|
||||||
|
);
|
||||||
|
throw new ServiceUnavailableException(
|
||||||
|
detail
|
||||||
|
? `Could not reach ArvanCloud DNS API (${detail})`
|
||||||
|
: 'Could not reach ArvanCloud DNS API',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const text = await response.text().catch(() => '');
|
const text = await response.text().catch(() => '');
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import { RefreshTokenDto } from './dto/refresh-token.dto';
|
|||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
import { ResetPasswordDto } from './dto/reset-password.dto';
|
import { ResetPasswordDto } from './dto/reset-password.dto';
|
||||||
import { SendOtpDto } from './dto/send-otp.dto';
|
import { SendOtpDto } from './dto/send-otp.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
|
import { SignupVerifyOtpDto } from './dto/signup-verify-otp.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
import { UpsertUserAddressDto } from './dto/upsert-user-address.dto';
|
||||||
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
import { VerifyOtpDto } from './dto/verify-otp.dto';
|
||||||
@@ -37,6 +40,21 @@ export class AuthController {
|
|||||||
return this.authService.register(dto);
|
return this.authService.register(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Post('signup/start')
|
||||||
|
startSignup(@Body() dto: SignupStartDto) {
|
||||||
|
return this.authService.startSignup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('signup/verify-otp')
|
||||||
|
verifySignupOtp(@Body() dto: SignupVerifyOtpDto) {
|
||||||
|
return this.authService.verifySignupOtp(dto.cellNumber, dto.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('signup/complete')
|
||||||
|
completeSignup(@Body() dto: SignupCompleteDto) {
|
||||||
|
return this.authService.completeSignup(dto);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('login')
|
@Post('login')
|
||||||
login(@Body() dto: LoginDto) {
|
login(@Body() dto: LoginDto) {
|
||||||
return this.authService.login(dto);
|
return this.authService.login(dto);
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { AuthService } from './auth.service';
|
|||||||
import { BusinessPermissionGuard } from './guards/business-permission.guard';
|
import { BusinessPermissionGuard } from './guards/business-permission.guard';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
import { SmsBillingService } from './sms-billing.service';
|
||||||
import { JwtStrategy } from './strategies/jwt.strategy';
|
import { JwtStrategy } from './strategies/jwt.strategy';
|
||||||
import { UserAddressesService } from './user-addresses.service';
|
import { UserAddressesService } from './user-addresses.service';
|
||||||
|
|
||||||
@@ -33,10 +34,17 @@ import { UserAddressesService } from './user-addresses.service';
|
|||||||
AuthService,
|
AuthService,
|
||||||
UserAddressesService,
|
UserAddressesService,
|
||||||
SmsService,
|
SmsService,
|
||||||
|
SmsBillingService,
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
BusinessPermissionGuard,
|
BusinessPermissionGuard,
|
||||||
JwtStrategy,
|
JwtStrategy,
|
||||||
],
|
],
|
||||||
exports: [AuthService, PermissionsService, BusinessPermissionGuard, SmsService],
|
exports: [
|
||||||
|
AuthService,
|
||||||
|
PermissionsService,
|
||||||
|
BusinessPermissionGuard,
|
||||||
|
SmsService,
|
||||||
|
SmsBillingService,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AuthModule {}
|
export class AuthModule {}
|
||||||
|
|||||||
+414
-42
@@ -24,12 +24,19 @@ import {
|
|||||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||||
import { LoginDto } from './dto/login.dto';
|
import { LoginDto } from './dto/login.dto';
|
||||||
import { RegisterDto } from './dto/register.dto';
|
import { RegisterDto } from './dto/register.dto';
|
||||||
|
import { SignupCompleteDto } from './dto/signup-complete.dto';
|
||||||
|
import { SignupStartDto } from './dto/signup-start.dto';
|
||||||
import { UpdateProfileDto } from './dto/update-profile.dto';
|
import { UpdateProfileDto } from './dto/update-profile.dto';
|
||||||
import { PermissionsService } from './permissions.service';
|
import { PermissionsService } from './permissions.service';
|
||||||
import { parseUserProfile } from './profile.util';
|
import { parseUserProfile } from './profile.util';
|
||||||
import { SmsService } from './sms.service';
|
import { SmsService } from './sms.service';
|
||||||
|
import { SmsBillingService } from './sms-billing.service';
|
||||||
|
|
||||||
const OTP_TTL_SECONDS = 300;
|
const OTP_TTL_SECONDS = 300;
|
||||||
|
/** Pending cross-site password change (longer than OTP so resend still works). */
|
||||||
|
const PENDING_PASSWORD_TTL_SECONDS = 600;
|
||||||
|
/** Pending signup + post-OTP registration token. */
|
||||||
|
const PENDING_SIGNUP_TTL_SECONDS = 600;
|
||||||
const HANDOFF_TTL_SECONDS = 60;
|
const HANDOFF_TTL_SECONDS = 60;
|
||||||
const HANDOFF_RATE_LIMIT = 10;
|
const HANDOFF_RATE_LIMIT = 10;
|
||||||
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
const HANDOFF_RATE_WINDOW_SECONDS = 60;
|
||||||
@@ -42,6 +49,7 @@ export class AuthService {
|
|||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
private readonly redis: RedisService,
|
private readonly redis: RedisService,
|
||||||
private readonly sms: SmsService,
|
private readonly sms: SmsService,
|
||||||
|
private readonly smsBilling: SmsBillingService,
|
||||||
private readonly tenant: TenantService,
|
private readonly tenant: TenantService,
|
||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
@@ -78,13 +86,14 @@ export class AuthService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let passwordMatchesExisting = false;
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
const passwordValid = await bcrypt.compare(
|
passwordMatchesExisting = await bcrypt.compare(
|
||||||
dto.password,
|
dto.password,
|
||||||
existingUser.passwordHash,
|
existingUser.passwordHash,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!passwordValid && !dto.acknowledgeExistingAccount) {
|
if (!passwordMatchesExisting && !dto.acknowledgeExistingAccount) {
|
||||||
const otherNames = existingUser.businessCustomers
|
const otherNames = existingUser.businessCustomers
|
||||||
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
@@ -98,18 +107,49 @@ export class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const linkedExistingAccount = Boolean(existingUser);
|
const linkedExistingAccount = Boolean(existingUser);
|
||||||
|
// New password claimed on another site — apply only after OTP proves ownership.
|
||||||
|
const wantsPasswordUpdate =
|
||||||
|
linkedExistingAccount && !passwordMatchesExisting;
|
||||||
|
|
||||||
|
// Hold new password in Redis until OTP; do not touch DB password or membership yet.
|
||||||
|
if (existingUser && wantsPasswordUpdate && smsEnabled) {
|
||||||
|
await this.redis.setPendingPasswordUpdate(
|
||||||
|
dto.cellNumber,
|
||||||
|
{
|
||||||
|
passwordHash,
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
},
|
||||||
|
PENDING_PASSWORD_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const authUser = await this.getAuthUser(existingUser.id);
|
||||||
|
return {
|
||||||
|
message:
|
||||||
|
'Please verify your cell number with OTP. Your Meshkee password will be updated after verification.',
|
||||||
|
smsEnabled: true,
|
||||||
|
requiresOtp: true,
|
||||||
|
passwordPending: true,
|
||||||
|
passwordUpdated: false,
|
||||||
|
user: this.serializeUser(authUser),
|
||||||
|
registeredBusiness: {
|
||||||
|
id: business.id,
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const userId = await this.prisma.$transaction(async (tx) => {
|
const userId = await this.prisma.$transaction(async (tx) => {
|
||||||
let userId: bigint;
|
let userId: bigint;
|
||||||
|
|
||||||
if (existingUser) {
|
if (existingUser) {
|
||||||
// Cross-site join: replace Meshkee password with the one entered on this signup.
|
if (wantsPasswordUpdate) {
|
||||||
const updated = await tx.user.update({
|
await tx.user.update({
|
||||||
where: { id: existingUser.id },
|
where: { id: existingUser.id },
|
||||||
data: { passwordHash },
|
data: { passwordHash },
|
||||||
select: { id: true },
|
|
||||||
});
|
});
|
||||||
userId = updated.id;
|
}
|
||||||
|
userId = existingUser.id;
|
||||||
} else {
|
} else {
|
||||||
const created = await tx.user.create({
|
const created = await tx.user.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -157,15 +197,280 @@ export class AuthService {
|
|||||||
const tokens = await this.issueTokens(authUser);
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: linkedExistingAccount
|
message: wantsPasswordUpdate
|
||||||
? smsEnabled
|
? 'Joined this website. Your Meshkee password was updated to the one you just entered.'
|
||||||
? 'Joined this website. Your Meshkee password was updated to the one you just entered. Please verify your cell number with OTP.'
|
: linkedExistingAccount
|
||||||
: 'Joined this website. Your Meshkee password was updated to the one you just entered.'
|
? 'Joined this website with your existing Meshkee account.'
|
||||||
: smsEnabled
|
: smsEnabled
|
||||||
? 'Registration successful. Please verify your cell number with OTP.'
|
? 'Registration successful. Please verify your cell number with OTP.'
|
||||||
: 'Registration successful. SMS verification is disabled — account auto-verified.',
|
: 'Registration successful. SMS verification is disabled — account auto-verified.',
|
||||||
smsEnabled,
|
smsEnabled,
|
||||||
passwordUpdated: linkedExistingAccount,
|
passwordUpdated: wantsPasswordUpdate,
|
||||||
|
user: this.serializeUser(authUser),
|
||||||
|
registeredBusiness: {
|
||||||
|
id: business.id,
|
||||||
|
name: business.name,
|
||||||
|
slug: business.slug,
|
||||||
|
},
|
||||||
|
...tokens,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 1: name + number. Stores pending signup and sends OTP
|
||||||
|
* (OTP is allowed before the user row exists).
|
||||||
|
*/
|
||||||
|
async startSignup(dto: SignupStartDto) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(dto.domain);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber: dto.cellNumber },
|
||||||
|
include: {
|
||||||
|
businessCustomers: {
|
||||||
|
include: { business: { select: { id: true, name: true, nameFa: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyOnThisSite = existingUser?.businessCustomers.some(
|
||||||
|
(item) => item.businessId === business.id,
|
||||||
|
);
|
||||||
|
if (alreadyOnThisSite) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.redis.setPendingSignup(
|
||||||
|
dto.cellNumber,
|
||||||
|
{
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
},
|
||||||
|
PENDING_SIGNUP_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
|
||||||
|
const smsEnabled = this.sms.isEnabled();
|
||||||
|
if (!smsEnabled) {
|
||||||
|
const sites = this.otherSiteNames(existingUser, business.id);
|
||||||
|
const registrationToken = await this.issueSignupVerifiedToken({
|
||||||
|
cellNumber: dto.cellNumber,
|
||||||
|
firstName: dto.firstName.trim(),
|
||||||
|
lastName: dto.lastName.trim(),
|
||||||
|
businessId: business.id.toString(),
|
||||||
|
existingUserId: existingUser ? existingUser.id.toString() : null,
|
||||||
|
});
|
||||||
|
await this.redis.deletePendingSignup(dto.cellNumber);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: false,
|
||||||
|
requiresOtp: false,
|
||||||
|
existingAccount: Boolean(existingUser),
|
||||||
|
sites,
|
||||||
|
registrationToken,
|
||||||
|
message:
|
||||||
|
'SMS verification is currently disabled. Continue to finish registration.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.dispatchOtpSms(dto.cellNumber, business);
|
||||||
|
|
||||||
|
return {
|
||||||
|
enabled: true,
|
||||||
|
requiresOtp: true,
|
||||||
|
message: 'Verification code sent',
|
||||||
|
expiresInSeconds: OTP_TTL_SECONDS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 2: verify OTP, then report whether the phone already has a
|
||||||
|
* Meshkee account. Returns a one-time registrationToken for step 3.
|
||||||
|
*/
|
||||||
|
async verifySignupOtp(cellNumber: string, code: string) {
|
||||||
|
const pending = await this.redis.getPendingSignup(cellNumber);
|
||||||
|
if (!pending) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Signup session expired. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.consumeOtpCode(cellNumber, code);
|
||||||
|
|
||||||
|
const businessId = BigInt(pending.businessId);
|
||||||
|
const existingUser = await this.prisma.user.findUnique({
|
||||||
|
where: { cellNumber },
|
||||||
|
include: {
|
||||||
|
businessCustomers: {
|
||||||
|
include: { business: { select: { id: true, name: true, nameFa: true } } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const alreadyOnThisSite = existingUser?.businessCustomers.some(
|
||||||
|
(item) => item.businessId === businessId,
|
||||||
|
);
|
||||||
|
if (alreadyOnThisSite) {
|
||||||
|
await this.redis.deletePendingSignup(cellNumber);
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const sites = this.otherSiteNames(existingUser, businessId);
|
||||||
|
const registrationToken = await this.issueSignupVerifiedToken({
|
||||||
|
cellNumber,
|
||||||
|
firstName: pending.firstName,
|
||||||
|
lastName: pending.lastName,
|
||||||
|
businessId: pending.businessId,
|
||||||
|
existingUserId: existingUser ? existingUser.id.toString() : null,
|
||||||
|
});
|
||||||
|
await this.redis.deletePendingSignup(cellNumber);
|
||||||
|
|
||||||
|
return {
|
||||||
|
verified: true,
|
||||||
|
existingAccount: Boolean(existingUser),
|
||||||
|
sites,
|
||||||
|
registrationToken,
|
||||||
|
message: existingUser
|
||||||
|
? 'Phone verified. You already have a Meshkee account — your password stays the same.'
|
||||||
|
: 'Phone verified. Choose a password to finish creating your account.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signup step 3: create a new account (password required) or link an
|
||||||
|
* existing Meshkee account (password unchanged).
|
||||||
|
*/
|
||||||
|
async completeSignup(dto: SignupCompleteDto) {
|
||||||
|
const verified = await this.redis.consumeSignupVerifiedToken(
|
||||||
|
dto.registrationToken,
|
||||||
|
);
|
||||||
|
if (!verified) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Registration session expired. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const businessId = BigInt(verified.businessId);
|
||||||
|
const business = await this.prisma.business.findUnique({
|
||||||
|
where: { id: businessId },
|
||||||
|
select: { id: true, name: true, slug: true },
|
||||||
|
});
|
||||||
|
if (!business) {
|
||||||
|
throw new BadRequestException('Business not found for this registration.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const customerRole = await this.prisma.role.findUnique({
|
||||||
|
where: { slug: 'customer' },
|
||||||
|
});
|
||||||
|
if (!customerRole) {
|
||||||
|
throw new Error('Customer role is missing. Run database migrations first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const linkedExisting = Boolean(verified.existingUserId);
|
||||||
|
if (!linkedExisting) {
|
||||||
|
const password = dto.password?.trim();
|
||||||
|
if (!password || password.length < 8) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Password is required (at least 8 characters) for new accounts.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userId = await this.prisma.$transaction(async (tx) => {
|
||||||
|
let userId: bigint;
|
||||||
|
|
||||||
|
if (verified.existingUserId) {
|
||||||
|
userId = BigInt(verified.existingUserId);
|
||||||
|
const existing = await tx.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
include: {
|
||||||
|
businessCustomers: { select: { businessId: true } },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Existing account not found. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
existing.businessCustomers.some((item) => item.businessId === businessId)
|
||||||
|
) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered on this website',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: {
|
||||||
|
isActive: true,
|
||||||
|
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const collision = await tx.user.findUnique({
|
||||||
|
where: { cellNumber: verified.cellNumber },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (collision) {
|
||||||
|
throw new ConflictException(
|
||||||
|
'This cell number is already registered. Please start registration again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const passwordHash = await bcrypt.hash(dto.password!.trim(), 10);
|
||||||
|
const created = await tx.user.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: verified.cellNumber,
|
||||||
|
passwordHash,
|
||||||
|
firstName: verified.firstName,
|
||||||
|
lastName: verified.lastName,
|
||||||
|
cellVerifiedAt: new Date(),
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
userId = created.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.businessCustomer.create({
|
||||||
|
data: {
|
||||||
|
businessId,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const hasCustomerRole = await tx.userRole.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_roleId: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!hasCustomerRole) {
|
||||||
|
await tx.userRole.create({
|
||||||
|
data: {
|
||||||
|
userId,
|
||||||
|
roleId: customerRole.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return userId;
|
||||||
|
});
|
||||||
|
|
||||||
|
const authUser = await this.getAuthUser(userId);
|
||||||
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: linkedExisting
|
||||||
|
? 'Joined this website with your existing Meshkee account.'
|
||||||
|
: 'Registration successful.',
|
||||||
|
existingAccount: linkedExisting,
|
||||||
|
passwordUpdated: false,
|
||||||
user: this.serializeUser(authUser),
|
user: this.serializeUser(authUser),
|
||||||
registeredBusiness: {
|
registeredBusiness: {
|
||||||
id: business.id,
|
id: business.id,
|
||||||
@@ -369,7 +674,7 @@ export class AuthService {
|
|||||||
return { message: 'Password changed successfully' };
|
return { message: 'Password changed successfully' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendOtp(cellNumber: string, domain?: string) {
|
async sendOtp(cellNumber: string, domain: string) {
|
||||||
if (!this.sms.isEnabled()) {
|
if (!this.sms.isEnabled()) {
|
||||||
return {
|
return {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
@@ -386,29 +691,15 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
throw new UnauthorizedException('Cell number is not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
let businessNameFa: string | undefined;
|
|
||||||
const host = domain?.trim();
|
const host = domain?.trim();
|
||||||
if (host) {
|
if (!host) {
|
||||||
try {
|
throw new BadRequestException(
|
||||||
|
'Domain is required to send SMS (prepaid balance is billed per business).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const business = await this.tenant.resolveBusinessByDomain(host);
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
businessNameFa =
|
await this.dispatchOtpSms(cellNumber, business);
|
||||||
business.nameFa?.trim() || business.name?.trim() || undefined;
|
|
||||||
} catch {
|
|
||||||
// Domain may be unknown — still send OTP without branding suffix
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const code = this.generateOtpCode();
|
|
||||||
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.sms.sendVerificationCode(cellNumber, code, businessNameFa);
|
|
||||||
} catch (err) {
|
|
||||||
if (err instanceof HttpException) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
throw new ServiceUnavailableException('Unable to send SMS');
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -439,10 +730,8 @@ export class AuthService {
|
|||||||
|
|
||||||
async loginWithOtp(cellNumber: string, code: string, domain?: string) {
|
async loginWithOtp(cellNumber: string, code: string, domain?: string) {
|
||||||
const user = await this.consumeOtp(cellNumber, code);
|
const user = await this.consumeOtp(cellNumber, code);
|
||||||
|
const pending = await this.redis.consumePendingPasswordUpdate(cellNumber);
|
||||||
if (domain?.trim()) {
|
const passwordUpdated = Boolean(pending?.passwordHash);
|
||||||
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.prisma.user.update({
|
await this.prisma.user.update({
|
||||||
where: { id: user.id },
|
where: { id: user.id },
|
||||||
@@ -451,14 +740,28 @@ export class AuthService {
|
|||||||
isActive: true,
|
isActive: true,
|
||||||
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
cellVerifiedAt: user.cellVerifiedAt ?? new Date(),
|
||||||
lastLoginAt: new Date(),
|
lastLoginAt: new Date(),
|
||||||
|
...(pending?.passwordHash
|
||||||
|
? { passwordHash: pending.passwordHash }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pending?.businessId) {
|
||||||
|
await this.ensureCustomerMembership(user.id, BigInt(pending.businessId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (domain?.trim()) {
|
||||||
|
await this.ensureCustomerMembershipForDomain(user.id, domain.trim());
|
||||||
|
}
|
||||||
|
|
||||||
const authUser = await this.getAuthUser(user.id);
|
const authUser = await this.getAuthUser(user.id);
|
||||||
const tokens = await this.issueTokens(authUser);
|
const tokens = await this.issueTokens(authUser);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
message: 'Login successful',
|
message: passwordUpdated
|
||||||
|
? 'Login successful. Your Meshkee password was updated.'
|
||||||
|
: 'Login successful',
|
||||||
|
passwordUpdated,
|
||||||
user: this.serializeUser(authUser),
|
user: this.serializeUser(authUser),
|
||||||
...tokens,
|
...tokens,
|
||||||
};
|
};
|
||||||
@@ -492,17 +795,83 @@ export class AuthService {
|
|||||||
throw new UnauthorizedException('Cell number is not registered');
|
throw new UnauthorizedException('Cell number is not registered');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!this.sms.isEnabled()) {
|
await this.consumeOtpCode(cellNumber, code);
|
||||||
return user;
|
return user;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Validates and consumes the OTP code without requiring a user row. */
|
||||||
|
private async consumeOtpCode(cellNumber: string, code: string) {
|
||||||
|
if (!this.sms.isEnabled()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const storedCode = await this.redis.getOtp(cellNumber);
|
const storedCode = await this.redis.getOtp(cellNumber);
|
||||||
if (!storedCode || storedCode !== code) {
|
if (!storedCode || storedCode !== code) {
|
||||||
throw new UnauthorizedException('Invalid or expired verification code');
|
throw new UnauthorizedException('Invalid or expired verification code');
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.redis.deleteOtp(cellNumber);
|
await this.redis.deleteOtp(cellNumber);
|
||||||
return user;
|
}
|
||||||
|
|
||||||
|
private async dispatchOtpSms(
|
||||||
|
cellNumber: string,
|
||||||
|
business: { id: bigint; name: string | null; nameFa: string | null },
|
||||||
|
) {
|
||||||
|
const businessNameFa =
|
||||||
|
business.nameFa?.trim() || business.name?.trim() || undefined;
|
||||||
|
const code = this.generateOtpCode();
|
||||||
|
const brand = businessNameFa?.trim();
|
||||||
|
const message = brand
|
||||||
|
? `کد تایید شما: ${code}\n${brand}`
|
||||||
|
: `کد تایید شما: ${code}`;
|
||||||
|
|
||||||
|
const charged = await this.smsBilling.charge(business.id, message, 1, 'auth');
|
||||||
|
await this.redis.setOtp(cellNumber, code, OTP_TTL_SECONDS);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.sms.sendMessage(cellNumber, message);
|
||||||
|
} catch (err) {
|
||||||
|
await this.smsBilling.refund(business.id, charged, 1, 'auth');
|
||||||
|
if (err instanceof HttpException) {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
throw new ServiceUnavailableException('Unable to send SMS');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private otherSiteNames(
|
||||||
|
existingUser:
|
||||||
|
| {
|
||||||
|
businessCustomers: {
|
||||||
|
businessId: bigint;
|
||||||
|
business: { name: string; nameFa: string | null };
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
| null
|
||||||
|
| undefined,
|
||||||
|
excludeBusinessId: bigint,
|
||||||
|
): string[] {
|
||||||
|
if (!existingUser) return [];
|
||||||
|
return existingUser.businessCustomers
|
||||||
|
.filter((item) => item.businessId !== excludeBusinessId)
|
||||||
|
.map((item) => item.business.nameFa?.trim() || item.business.name)
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async issueSignupVerifiedToken(payload: {
|
||||||
|
cellNumber: string;
|
||||||
|
firstName: string;
|
||||||
|
lastName: string;
|
||||||
|
businessId: string;
|
||||||
|
existingUserId: string | null;
|
||||||
|
}): Promise<string> {
|
||||||
|
const registrationToken = randomBytes(32).toString('hex');
|
||||||
|
await this.redis.setSignupVerifiedToken(
|
||||||
|
registrationToken,
|
||||||
|
payload,
|
||||||
|
PENDING_SIGNUP_TTL_SECONDS,
|
||||||
|
);
|
||||||
|
return registrationToken;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureCustomerMembershipForDomain(
|
private async ensureCustomerMembershipForDomain(
|
||||||
@@ -510,17 +879,20 @@ export class AuthService {
|
|||||||
domain: string,
|
domain: string,
|
||||||
) {
|
) {
|
||||||
const business = await this.tenant.resolveBusinessByDomain(domain);
|
const business = await this.tenant.resolveBusinessByDomain(domain);
|
||||||
|
await this.ensureCustomerMembership(userId, business.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureCustomerMembership(userId: bigint, businessId: bigint) {
|
||||||
const existing = await this.prisma.businessCustomer.findUnique({
|
const existing = await this.prisma.businessCustomer.findUnique({
|
||||||
where: {
|
where: {
|
||||||
businessId_userId: { businessId: business.id, userId },
|
businessId_userId: { businessId, userId },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
await this.prisma.businessCustomer.create({
|
await this.prisma.businessCustomer.create({
|
||||||
data: {
|
data: {
|
||||||
businessId: business.id,
|
businessId,
|
||||||
userId,
|
userId,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { IsOptional, IsString, Matches, MaxLength } from 'class-validator';
|
import { IsString, Matches, MaxLength } from 'class-validator';
|
||||||
|
|
||||||
export class SendOtpDto {
|
export class SendOtpDto {
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -7,9 +7,8 @@ export class SendOtpDto {
|
|||||||
})
|
})
|
||||||
cellNumber!: string;
|
cellNumber!: string;
|
||||||
|
|
||||||
/** Tenant host/apex (e.g. sanihome.ir or business.sanihome.ir) — used to brand OTP SMS. */
|
/** Tenant host/apex — required so OTP SMS is billed to the business. */
|
||||||
@IsOptional()
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(253)
|
@MaxLength(253)
|
||||||
domain?: string;
|
domain!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { IsOptional, IsString, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupCompleteDto {
|
||||||
|
/** One-time token from signup/verify-otp (or signup/start when SMS is off). */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(16)
|
||||||
|
registrationToken!: string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Required when creating a brand-new Meshkee account.
|
||||||
|
* Ignored when linking an existing account (password stays unchanged).
|
||||||
|
*/
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(8, { message: 'password must be at least 8 characters' })
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { IsString, Matches, MaxLength, MinLength } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupStartDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||||
|
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
|
})
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
firstName!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
lastName!: string;
|
||||||
|
|
||||||
|
/** Domain of the business website (e.g. shop-a.local). */
|
||||||
|
@IsString()
|
||||||
|
@MinLength(3)
|
||||||
|
@MaxLength(253)
|
||||||
|
domain!: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { IsString, Length, Matches } from 'class-validator';
|
||||||
|
|
||||||
|
export class SignupVerifyOtpDto {
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^\+[1-9]\d{6,14}$/, {
|
||||||
|
message: 'cellNumber must be in E.164 format (e.g. +989121234567)',
|
||||||
|
})
|
||||||
|
cellNumber!: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@Length(6, 6)
|
||||||
|
@Matches(/^\d{6}$/, { message: 'code must be a 6-digit number' })
|
||||||
|
code!: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Prepaid SMS billing in credit units.
|
||||||
|
* - 1 credit = one 64-character segment for one recipient
|
||||||
|
* - Top-up: floor(IRT / 500) credits (e.g. 1,000,000 IRT → 2,000 credits)
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SMS_SEGMENT_CHARS = 64;
|
||||||
|
/** IRT (toman) price to purchase one SMS credit. */
|
||||||
|
export const SMS_PURCHASE_PRICE_PER_CREDIT_IRT = 500;
|
||||||
|
/** Starting credits for newly created businesses (≈ 500,000 IRT at purchase price). */
|
||||||
|
export const DEFAULT_NEW_BUSINESS_SMS_CREDITS = 1_000;
|
||||||
|
|
||||||
|
/** Lifetime send counter categories (one unit = one recipient). */
|
||||||
|
export type SmsSendType =
|
||||||
|
| 'mass'
|
||||||
|
| 'singular'
|
||||||
|
| 'auth'
|
||||||
|
| 'auto_customer'
|
||||||
|
| 'admin';
|
||||||
|
|
||||||
|
export const SMS_SEND_TYPES: readonly SmsSendType[] = [
|
||||||
|
'mass',
|
||||||
|
'singular',
|
||||||
|
'auth',
|
||||||
|
'auto_customer',
|
||||||
|
'admin',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
/** Whitelisted DB columns for typed send counters. */
|
||||||
|
export function smsSentColumnForType(type: SmsSendType): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'mass':
|
||||||
|
return 'sms_sent_mass';
|
||||||
|
case 'singular':
|
||||||
|
return 'sms_sent_singular';
|
||||||
|
case 'auth':
|
||||||
|
return 'sms_sent_auth';
|
||||||
|
case 'auto_customer':
|
||||||
|
return 'sms_sent_auto_customer';
|
||||||
|
case 'admin':
|
||||||
|
return 'sms_sent_admin';
|
||||||
|
default: {
|
||||||
|
const _exhaustive: never = type;
|
||||||
|
return _exhaustive;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Credits charged for one or more recipients.
|
||||||
|
* 1–64 chars → 1, 65–128 → 2, etc.
|
||||||
|
*/
|
||||||
|
export function calculateSmsCredits(
|
||||||
|
messageLength: number,
|
||||||
|
recipientCount = 1,
|
||||||
|
): number {
|
||||||
|
const length = Math.max(0, Math.floor(messageLength));
|
||||||
|
const recipients = Math.max(0, Math.floor(recipientCount));
|
||||||
|
if (recipients === 0 || length === 0) return 0;
|
||||||
|
const segments = Math.max(1, Math.ceil(length / SMS_SEGMENT_CHARS));
|
||||||
|
return segments * recipients;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Convert a top-up amount in IRT into SMS credits. */
|
||||||
|
export function irtToSmsCredits(irtAmount: number): number {
|
||||||
|
const irt = Math.max(0, Math.floor(irtAmount));
|
||||||
|
if (irt <= 0) return 0;
|
||||||
|
return Math.floor(irt / SMS_PURCHASE_PRICE_PER_CREDIT_IRT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use calculateSmsCredits — balance is no longer stored in IRT. */
|
||||||
|
export function calculateSmsCostIrt(
|
||||||
|
messageLength: number,
|
||||||
|
recipientCount = 1,
|
||||||
|
): number {
|
||||||
|
return calculateSmsCredits(messageLength, recipientCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Use DEFAULT_NEW_BUSINESS_SMS_CREDITS */
|
||||||
|
export const DEFAULT_NEW_BUSINESS_SMS_BALANCE_IRT = DEFAULT_NEW_BUSINESS_SMS_CREDITS;
|
||||||
|
|
||||||
|
/** @deprecated Purchase price constant — not a per-send IRT charge. */
|
||||||
|
export const SMS_COST_PER_SEGMENT_IRT = SMS_PURCHASE_PRICE_PER_CREDIT_IRT;
|
||||||
+281
-7
@@ -10,6 +10,22 @@ export type SmsSendResult = {
|
|||||||
serverId: string;
|
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 = {
|
type GamaQuickResponse = {
|
||||||
success?: boolean;
|
success?: boolean;
|
||||||
message?: string | null;
|
message?: string | null;
|
||||||
@@ -17,6 +33,13 @@ type GamaQuickResponse = {
|
|||||||
body?: number | string | null;
|
body?: number | string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type GamaBulkResponse = {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string | null;
|
||||||
|
errors?: unknown;
|
||||||
|
body?: Array<number | string> | null;
|
||||||
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SmsService {
|
export class SmsService {
|
||||||
private readonly logger = new Logger(SmsService.name);
|
private readonly logger = new Logger(SmsService.name);
|
||||||
@@ -27,6 +50,72 @@ export class SmsService {
|
|||||||
return this.config.get<string>('SMS_ENABLED', 'false') === 'true';
|
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(
|
async sendVerificationCode(
|
||||||
cellNumber: string,
|
cellNumber: string,
|
||||||
code: string,
|
code: string,
|
||||||
@@ -46,7 +135,11 @@ export class SmsService {
|
|||||||
return this.sendMessage(cellNumber, message);
|
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()) {
|
if (!this.isEnabled()) {
|
||||||
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
this.logger.warn(`SMS disabled — message for ${cellNumber} not sent: ${message}`);
|
||||||
return { serverId: 'disabled' };
|
return { serverId: 'disabled' };
|
||||||
@@ -65,11 +158,78 @@ export class SmsService {
|
|||||||
throw new BadRequestException('Invalid cellphone number');
|
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()) {
|
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 {
|
private isProxyConfigured(): boolean {
|
||||||
@@ -80,7 +240,11 @@ export class SmsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Local-dev path: deliver via remote Meshkee partner SMS gateway (production reaches Gama). */
|
/** 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 url = this.config.get<string>('SMS_PROXY_URL')!.trim();
|
||||||
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
const apiKey = this.config.get<string>('SMS_PROXY_API_KEY')!.trim();
|
||||||
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
const domain = this.config.get<string>('SMS_PROXY_DOMAIN')!.trim();
|
||||||
@@ -99,6 +263,7 @@ export class SmsService {
|
|||||||
domain,
|
domain,
|
||||||
to: destination,
|
to: destination,
|
||||||
message,
|
message,
|
||||||
|
...(source ? { source } : {}),
|
||||||
}),
|
}),
|
||||||
signal: AbortSignal.timeout(20_000),
|
signal: AbortSignal.timeout(20_000),
|
||||||
});
|
});
|
||||||
@@ -144,10 +309,16 @@ export class SmsService {
|
|||||||
return { serverId };
|
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 username = this.config.get<string>('SMS_GAMA_USERNAME')?.trim();
|
||||||
const password = this.config.get<string>('SMS_GAMA_PASSWORD')?.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 = (
|
const baseUrl = (
|
||||||
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
this.config.get<string>('SMS_GAMA_BASE_URL') ?? 'https://sms.igama.ir/api/v1'
|
||||||
).replace(/\/$/, '');
|
).replace(/\/$/, '');
|
||||||
@@ -209,9 +380,97 @@ export class SmsService {
|
|||||||
throw new ServiceUnavailableException('SMS provider returned an empty server id');
|
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 };
|
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). */
|
/** Accept 09… / 9… / +989… / 989… → Gama MSISDN `989…` (no plus). */
|
||||||
@@ -242,6 +501,18 @@ export function toGamaMsisdn(raw: string): string | null {
|
|||||||
return msisdn;
|
return msisdn;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Iranian mobile only (local 09xxxxxxxxx → `989xxxxxxxxx`).
|
||||||
|
* Rejects landlines, foreign numbers, and invalid formats.
|
||||||
|
*/
|
||||||
|
export function toIranianMobileMsisdn(raw: string): string | null {
|
||||||
|
const msisdn = toGamaMsisdn(raw);
|
||||||
|
if (!msisdn || !/^989\d{9}$/.test(msisdn)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return msisdn;
|
||||||
|
}
|
||||||
|
|
||||||
export function maskMsisdn(msisdn: string): string {
|
export function maskMsisdn(msisdn: string): string {
|
||||||
if (msisdn.length < 6) return '***';
|
if (msisdn.length < 6) return '***';
|
||||||
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
return `${msisdn.slice(0, 4)}****${msisdn.slice(-3)}`;
|
||||||
@@ -273,5 +544,8 @@ function mapGamaError(message: string): Error {
|
|||||||
if (lower.includes('message is empty')) {
|
if (lower.includes('message is empty')) {
|
||||||
return new BadRequestException('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');
|
return new ServiceUnavailableException('SMS provider rejected the request');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
Controller,
|
Controller,
|
||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
|
Headers,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
@@ -14,6 +15,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
|||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { WebsiteAnalyticsService } from '../website-analytics/website-analytics.service';
|
||||||
import { BlogsService } from './blogs.service';
|
import { BlogsService } from './blogs.service';
|
||||||
import {
|
import {
|
||||||
CreateBlogCommentDto,
|
CreateBlogCommentDto,
|
||||||
@@ -92,13 +94,33 @@ export class BlogsController {
|
|||||||
|
|
||||||
@Controller('tenants/:host/blogs')
|
@Controller('tenants/:host/blogs')
|
||||||
export class PublicBlogsController {
|
export class PublicBlogsController {
|
||||||
constructor(private readonly service: BlogsService) {}
|
constructor(
|
||||||
|
private readonly service: BlogsService,
|
||||||
|
private readonly analytics: WebsiteAnalyticsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
list(@Param('host') host: string, @Query() query: ListPublicBlogsDto) {
|
||||||
return this.service.listPublic(host, query);
|
return this.service.listPublic(host, query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('by-id/:blogId')
|
||||||
|
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,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
@Get(':blogId/comments')
|
@Get(':blogId/comments')
|
||||||
listComments(@Param('host') host: string, @Param('blogId') blogId: string) {
|
listComments(@Param('host') host: string, @Param('blogId') blogId: string) {
|
||||||
return this.service.listCommentsPublic(host, blogId);
|
return this.service.listCommentsPublic(host, blogId);
|
||||||
@@ -114,7 +136,19 @@ export class PublicBlogsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':slug')
|
@Get(':slug')
|
||||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
async getBySlug(
|
||||||
return this.service.getPublicBySlug(host, slug);
|
@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,
|
||||||
|
undefined,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,18 @@ import { AuthModule } from '../auth/auth.module';
|
|||||||
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
import { BusinessSettingsModule } from '../business-settings/business-settings.module';
|
||||||
import { SitemapModule } from '../sitemap/sitemap.module';
|
import { SitemapModule } from '../sitemap/sitemap.module';
|
||||||
import { TenantModule } from '../tenant/tenant.module';
|
import { TenantModule } from '../tenant/tenant.module';
|
||||||
|
import { WebsiteAnalyticsModule } from '../website-analytics/website-analytics.module';
|
||||||
import { BlogsController, PublicBlogsController } from './blogs.controller';
|
import { BlogsController, PublicBlogsController } from './blogs.controller';
|
||||||
import { BlogsService } from './blogs.service';
|
import { BlogsService } from './blogs.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, BusinessSettingsModule, SitemapModule, TenantModule],
|
imports: [
|
||||||
|
AuthModule,
|
||||||
|
BusinessSettingsModule,
|
||||||
|
SitemapModule,
|
||||||
|
TenantModule,
|
||||||
|
WebsiteAnalyticsModule,
|
||||||
|
],
|
||||||
controllers: [BlogsController, PublicBlogsController],
|
controllers: [BlogsController, PublicBlogsController],
|
||||||
providers: [BlogsService],
|
providers: [BlogsService],
|
||||||
})
|
})
|
||||||
|
|||||||
+120
-7
@@ -12,7 +12,16 @@ import {
|
|||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { BusinessSettingsService } from '../business-settings/business-settings.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 { 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 { SitemapService } from '../sitemap/sitemap.service';
|
||||||
import { TenantService } from '../tenant/tenant.service';
|
import { TenantService } from '../tenant/tenant.service';
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +147,10 @@ export class BlogsService {
|
|||||||
status,
|
status,
|
||||||
featured_media_id: featuredMediaId,
|
featured_media_id: featuredMediaId,
|
||||||
published_at: status === ContentStatus.published ? new Date() : null,
|
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,
|
include: blogInclude,
|
||||||
});
|
});
|
||||||
@@ -218,10 +230,13 @@ export class BlogsService {
|
|||||||
nextContent.html = dto.mainTextHtml ?? '';
|
nextContent.html = dto.mainTextHtml ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextMetadata = { ...existingMetadata };
|
const nextMetadata = applySeoMetaToMetadata(
|
||||||
if (dto.tags !== undefined) {
|
{
|
||||||
nextMetadata.tags = dto.tags;
|
...existingMetadata,
|
||||||
}
|
...(dto.tags !== undefined ? { tags: dto.tags } : {}),
|
||||||
|
},
|
||||||
|
normalizeSeoMetaInput(dto.seoMetaTitle, dto.seoMetaDescription),
|
||||||
|
);
|
||||||
|
|
||||||
let publishedAt: Date | null | undefined = undefined;
|
let publishedAt: Date | null | undefined = undefined;
|
||||||
if (dto.status !== undefined) {
|
if (dto.status !== undefined) {
|
||||||
@@ -374,10 +389,99 @@ export class BlogsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
blog: await this.serializeBlog(blog, {
|
blog: await this.serializePublicBlog(host, business, blog),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPublicById(host: string, blogIdRaw: string) {
|
||||||
|
const business = await this.tenant.resolveBusinessByDomain(host);
|
||||||
|
const businessId = business.id;
|
||||||
|
let blogId: bigint;
|
||||||
|
try {
|
||||||
|
blogId = BigInt(blogIdRaw);
|
||||||
|
} catch {
|
||||||
|
throw new NotFoundException('Blog post not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const blog = await this.prisma.blogs.findFirst({
|
||||||
|
where: {
|
||||||
|
business_id: businessId,
|
||||||
|
id: blogId,
|
||||||
|
status: ContentStatus.published,
|
||||||
|
},
|
||||||
|
include: blogInclude,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!blog) {
|
||||||
|
throw new NotFoundException('Blog post not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
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,
|
includeComments: true,
|
||||||
approvedCommentsOnly: 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 },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -502,6 +606,14 @@ export class BlogsService {
|
|||||||
title: { contains: query.title.trim(), mode: 'insensitive' },
|
title: { contains: query.title.trim(), mode: 'insensitive' },
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(query.tag?.trim()
|
||||||
|
? {
|
||||||
|
metadata: {
|
||||||
|
path: ['tags'],
|
||||||
|
array_contains: query.tag.trim(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,6 +681,7 @@ export class BlogsService {
|
|||||||
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
categoryId: categoryAssignment?.categoryId.toString() ?? null,
|
||||||
categoryName: categoryAssignment?.category.name ?? '',
|
categoryName: categoryAssignment?.category.name ?? '',
|
||||||
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
tags: Array.isArray(metadata.tags) ? (metadata.tags as string[]) : [],
|
||||||
|
...readSeoMetaFromMetadata(metadata),
|
||||||
authorId: blog.author_id?.toString() ?? null,
|
authorId: blog.author_id?.toString() ?? null,
|
||||||
author: blog.author
|
author: blog.author
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Matches,
|
Matches,
|
||||||
Min,
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
|
MaxLength,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class ListBlogsDto {
|
export class ListBlogsDto {
|
||||||
@@ -39,6 +40,10 @@ export class ListBlogsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class ListPublicBlogsDto {
|
export class ListPublicBlogsDto {
|
||||||
@@ -65,6 +70,10 @@ export class ListPublicBlogsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
title?: string;
|
title?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
tag?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class CreateBlogDto {
|
export class CreateBlogDto {
|
||||||
@@ -99,6 +108,16 @@ export class CreateBlogDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -144,6 +163,16 @@ export class UpdateBlogDto {
|
|||||||
@IsArray()
|
@IsArray()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
tags?: string[];
|
tags?: string[];
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(70)
|
||||||
|
seoMetaTitle?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(160)
|
||||||
|
seoMetaDescription?: string | null;
|
||||||
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { ArvanDnsModule } from '../arvan-dns/arvan-dns.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 { WebsiteDeployModule } from '../website-deploy/website-deploy.module';
|
||||||
import { BusinessCategoriesController } from './business-categories.controller';
|
import { BusinessCategoriesController } from './business-categories.controller';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
@@ -10,7 +11,7 @@ import { LegacyMigrateService } from './legacy-migrate.service';
|
|||||||
import { LegacyPurgeService } from './legacy-purge.service';
|
import { LegacyPurgeService } from './legacy-purge.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule],
|
imports: [AuthModule, WebsiteDeployModule, ArvanDnsModule, CloudflareDnsModule],
|
||||||
controllers: [BusinessAdminController, BusinessCategoriesController],
|
controllers: [BusinessAdminController, BusinessCategoriesController],
|
||||||
providers: [
|
providers: [
|
||||||
BusinessAdminService,
|
BusinessAdminService,
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import {
|
|||||||
import { Prisma } from '@prisma/client';
|
import { Prisma } from '@prisma/client';
|
||||||
import * as bcrypt from 'bcrypt';
|
import * as bcrypt from 'bcrypt';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { AddDomainDto } from './dto/add-domain.dto';
|
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 { normalizeBusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import {
|
import {
|
||||||
|
mergeBusinessSettings,
|
||||||
|
normalizeBusinessSettings,
|
||||||
normalizeDashboardLocale,
|
normalizeDashboardLocale,
|
||||||
normalizeDashboardThemeMode,
|
normalizeDashboardThemeMode,
|
||||||
normalizeEnabledBusinessModules,
|
normalizeEnabledBusinessModules,
|
||||||
normalizeHomeCharts,
|
normalizeHomeCharts,
|
||||||
|
normalizeSmsSenderNumber,
|
||||||
toPrismaJson,
|
toPrismaJson,
|
||||||
} from '../business-settings/business-settings.util';
|
} from '../business-settings/business-settings.util';
|
||||||
import type {
|
import type {
|
||||||
@@ -45,6 +52,7 @@ import {
|
|||||||
DEFAULT_NEW_BUSINESS_MODULES,
|
DEFAULT_NEW_BUSINESS_MODULES,
|
||||||
} from '../business-settings/business-settings.types';
|
} from '../business-settings/business-settings.types';
|
||||||
import { ArvanDnsService } from '../arvan-dns/arvan-dns.service';
|
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 { WebsiteDeployAgentService } from '../website-deploy/website-deploy-agent.service';
|
||||||
import {
|
import {
|
||||||
deploySlugFromHost,
|
deploySlugFromHost,
|
||||||
@@ -59,6 +67,7 @@ type BusinessRow = {
|
|||||||
about: string | null;
|
about: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
annualRenewalAt: Date | null;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
oldBusinessId: bigint | null;
|
oldBusinessId: bigint | null;
|
||||||
domainId: bigint | null;
|
domainId: bigint | null;
|
||||||
@@ -75,6 +84,14 @@ type BusinessRow = {
|
|||||||
themeMode: string | null;
|
themeMode: string | null;
|
||||||
enabledModules: unknown;
|
enabledModules: unknown;
|
||||||
homeCharts: unknown;
|
homeCharts: unknown;
|
||||||
|
smsSenderNumber: string | null;
|
||||||
|
smsBalance: number;
|
||||||
|
smsSentCount: number;
|
||||||
|
smsSentMass: number;
|
||||||
|
smsSentSingular: number;
|
||||||
|
smsSentAuth: number;
|
||||||
|
smsSentAutoCustomer: number;
|
||||||
|
smsSentAdmin: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
function slugify(value: string) {
|
function slugify(value: string) {
|
||||||
@@ -94,6 +111,7 @@ export class BusinessAdminService {
|
|||||||
private readonly legacyPurge: LegacyPurgeService,
|
private readonly legacyPurge: LegacyPurgeService,
|
||||||
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
private readonly websiteDeployAgent: WebsiteDeployAgentService,
|
||||||
private readonly arvanDns: ArvanDnsService,
|
private readonly arvanDns: ArvanDnsService,
|
||||||
|
private readonly cloudflareDns: CloudflareDnsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
private async assertSuperAdmin(actor: AuthUser) {
|
private async assertSuperAdmin(actor: AuthUser) {
|
||||||
@@ -112,6 +130,8 @@ export class BusinessAdminService {
|
|||||||
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
const nameLike = query.name ? `%${query.name.trim()}%` : null;
|
||||||
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
const domainLike = query.domain ? `%${query.domain.trim()}%` : null;
|
||||||
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
const categoryLike = query.category ? `%${query.category.trim()}%` : null;
|
||||||
|
const sortByRenewal = query.sort === 'renewal';
|
||||||
|
const lowSmsThreshold = 100;
|
||||||
|
|
||||||
const where = Prisma.sql`
|
const where = Prisma.sql`
|
||||||
WHERE 1=1
|
WHERE 1=1
|
||||||
@@ -133,7 +153,40 @@ export class BusinessAdminService {
|
|||||||
` : Prisma.empty}
|
` : Prisma.empty}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const [items, totalRow] = await Promise.all([
|
// Matches super-admin nextAnnualRenewal(): future contract date as-is,
|
||||||
|
// otherwise this calendar year's month/day (overdue if already past).
|
||||||
|
const nextRenewalDateSql = Prisma.sql`
|
||||||
|
CASE
|
||||||
|
WHEN COALESCE(b.annual_renewal_at, b.created_at) IS NULL THEN NULL
|
||||||
|
WHEN COALESCE(b.annual_renewal_at, b.created_at)::date > CURRENT_DATE
|
||||||
|
THEN COALESCE(b.annual_renewal_at, b.created_at)::date
|
||||||
|
ELSE make_date(
|
||||||
|
EXTRACT(YEAR FROM CURRENT_DATE)::int,
|
||||||
|
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
LEAST(
|
||||||
|
EXTRACT(DAY FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
EXTRACT(
|
||||||
|
DAY FROM (
|
||||||
|
DATE_TRUNC(
|
||||||
|
'month',
|
||||||
|
make_date(
|
||||||
|
EXTRACT(YEAR FROM CURRENT_DATE)::int,
|
||||||
|
EXTRACT(MONTH FROM COALESCE(b.annual_renewal_at, b.created_at))::int,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
) + INTERVAL '1 month - 1 day'
|
||||||
|
)
|
||||||
|
)::int
|
||||||
|
)
|
||||||
|
)
|
||||||
|
END
|
||||||
|
`;
|
||||||
|
|
||||||
|
const orderBy = sortByRenewal
|
||||||
|
? Prisma.sql`ORDER BY ${nextRenewalDateSql} ASC NULLS LAST, b.created_at DESC`
|
||||||
|
: Prisma.sql`ORDER BY b.created_at DESC`;
|
||||||
|
|
||||||
|
const [items, totalRow, lowSmsRows] = await Promise.all([
|
||||||
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
this.prisma.$queryRaw<BusinessRow[]>(Prisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
b.id AS "id",
|
b.id AS "id",
|
||||||
@@ -142,6 +195,7 @@ export class BusinessAdminService {
|
|||||||
b.about AS "about",
|
b.about AS "about",
|
||||||
b.slug AS "slug",
|
b.slug AS "slug",
|
||||||
b.created_at AS "createdAt",
|
b.created_at AS "createdAt",
|
||||||
|
b.annual_renewal_at AS "annualRenewalAt",
|
||||||
b.is_active AS "isActive",
|
b.is_active AS "isActive",
|
||||||
b.old_business_id AS "oldBusinessId",
|
b.old_business_id AS "oldBusinessId",
|
||||||
dom.id AS "domainId",
|
dom.id AS "domainId",
|
||||||
@@ -157,7 +211,15 @@ export class BusinessAdminService {
|
|||||||
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
||||||
b.settings->'branding'->>'themeMode' AS "themeMode",
|
b.settings->'branding'->>'themeMode' AS "themeMode",
|
||||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
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
|
FROM businesses b
|
||||||
LEFT JOIN LATERAL (
|
LEFT JOIN LATERAL (
|
||||||
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
|
SELECT d.id, d.host, d.ssl_enabled, d.git_repo_url, d.deploy_slug
|
||||||
@@ -178,7 +240,7 @@ export class BusinessAdminService {
|
|||||||
LIMIT 1
|
LIMIT 1
|
||||||
) own ON TRUE
|
) own ON TRUE
|
||||||
${where}
|
${where}
|
||||||
ORDER BY b.created_at DESC
|
${orderBy}
|
||||||
LIMIT ${pageSize} OFFSET ${skip}
|
LIMIT ${pageSize} OFFSET ${skip}
|
||||||
`),
|
`),
|
||||||
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
this.prisma.$queryRaw<{ total: number }[]>(Prisma.sql`
|
||||||
@@ -186,6 +248,30 @@ export class BusinessAdminService {
|
|||||||
FROM businesses b
|
FROM businesses b
|
||||||
${where}
|
${where}
|
||||||
`),
|
`),
|
||||||
|
this.prisma.$queryRaw<
|
||||||
|
{
|
||||||
|
id: bigint;
|
||||||
|
name: string;
|
||||||
|
domain: string | null;
|
||||||
|
smsBalance: number;
|
||||||
|
}[]
|
||||||
|
>(Prisma.sql`
|
||||||
|
SELECT
|
||||||
|
b.id AS "id",
|
||||||
|
b.name AS "name",
|
||||||
|
dom.host AS "domain",
|
||||||
|
b.sms_balance AS "smsBalance"
|
||||||
|
FROM businesses b
|
||||||
|
LEFT JOIN LATERAL (
|
||||||
|
SELECT d.host
|
||||||
|
FROM domains d
|
||||||
|
WHERE d.business_id = b.id
|
||||||
|
ORDER BY d.is_primary DESC, d.created_at DESC
|
||||||
|
LIMIT 1
|
||||||
|
) dom ON TRUE
|
||||||
|
WHERE b.sms_balance < ${lowSmsThreshold}
|
||||||
|
ORDER BY b.sms_balance ASC, b.name ASC
|
||||||
|
`),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -218,11 +304,28 @@ export class BusinessAdminService {
|
|||||||
enabledModules,
|
enabledModules,
|
||||||
moduleCount: enabledModules.length,
|
moduleCount: enabledModules.length,
|
||||||
homeCharts,
|
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,
|
total: totalRow[0]?.total ?? 0,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
|
lowSmsBusinesses: lowSmsRows.map((row) => ({
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
domain: row.domain,
|
||||||
|
smsBalance: Number(row.smsBalance ?? 0),
|
||||||
|
})),
|
||||||
|
lowSmsThreshold,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,15 +342,27 @@ export class BusinessAdminService {
|
|||||||
name: string;
|
name: string;
|
||||||
nameFa: string | null;
|
nameFa: string | null;
|
||||||
slug: string;
|
slug: string;
|
||||||
|
ownerName: string | null;
|
||||||
|
ownerCellNumber: string | null;
|
||||||
}[]
|
}[]
|
||||||
>(Prisma.sql`
|
>(Prisma.sql`
|
||||||
SELECT DISTINCT
|
SELECT DISTINCT
|
||||||
b.id AS "id",
|
b.id AS "id",
|
||||||
b.name AS "name",
|
b.name AS "name",
|
||||||
b.name_fa AS "nameFa",
|
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
|
FROM businesses b
|
||||||
LEFT JOIN domains d ON d.business_id = b.id
|
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
|
WHERE b.is_active = TRUE
|
||||||
AND (
|
AND (
|
||||||
b.name ILIKE ${like}
|
b.name ILIKE ${like}
|
||||||
@@ -266,6 +381,8 @@ export class BusinessAdminService {
|
|||||||
nameFa: business.nameFa,
|
nameFa: business.nameFa,
|
||||||
slug: business.slug,
|
slug: business.slug,
|
||||||
label: this.formatBusinessLabel(business),
|
label: this.formatBusinessLabel(business),
|
||||||
|
ownerName: business.ownerName,
|
||||||
|
ownerCellNumber: business.ownerCellNumber,
|
||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -336,6 +453,8 @@ export class BusinessAdminService {
|
|||||||
take: 1,
|
take: 1,
|
||||||
},
|
},
|
||||||
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
|
domains: { orderBy: [{ isPrimary: 'desc' }, { createdAt: 'asc' }] },
|
||||||
|
logoMedia: true,
|
||||||
|
bannerMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -372,8 +491,12 @@ export class BusinessAdminService {
|
|||||||
nameFa: dto.nameFa.trim(),
|
nameFa: dto.nameFa.trim(),
|
||||||
about: dto.about?.trim() ?? null,
|
about: dto.about?.trim() ?? null,
|
||||||
slug,
|
slug,
|
||||||
|
annualRenewalAt: dto.annualRenewalAt
|
||||||
|
? new Date(dto.annualRenewalAt)
|
||||||
|
: new Date(),
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
dto.oldBusinessId !== undefined ? BigInt(dto.oldBusinessId) : undefined,
|
||||||
|
smsBalance: DEFAULT_NEW_BUSINESS_SMS_CREDITS,
|
||||||
settings: toPrismaJson({
|
settings: toPrismaJson({
|
||||||
...DEFAULT_BUSINESS_SETTINGS,
|
...DEFAULT_BUSINESS_SETTINGS,
|
||||||
modules: {
|
modules: {
|
||||||
@@ -441,7 +564,47 @@ export class BusinessAdminService {
|
|||||||
await this.assertOldBusinessIdAvailable(BigInt(dto.oldBusinessId), businessId);
|
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) => {
|
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({
|
await tx.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
data: {
|
data: {
|
||||||
@@ -455,6 +618,18 @@ export class BusinessAdminService {
|
|||||||
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
dto.oldBusinessId === null ? null : BigInt(dto.oldBusinessId),
|
||||||
}
|
}
|
||||||
: {}),
|
: {}),
|
||||||
|
...(dto.annualRenewalAt !== undefined
|
||||||
|
? {
|
||||||
|
annualRenewalAt:
|
||||||
|
dto.annualRenewalAt === null
|
||||||
|
? null
|
||||||
|
: new Date(dto.annualRenewalAt),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(nextSettings !== undefined ? { settings: nextSettings } : {}),
|
||||||
|
...(topUpCredits > 0
|
||||||
|
? { smsBalance: { increment: topUpCredits } }
|
||||||
|
: {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -558,7 +733,9 @@ export class BusinessAdminService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
|
const dnsError = dto.updateDns
|
||||||
|
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...domain,
|
...domain,
|
||||||
@@ -606,15 +783,19 @@ export class BusinessAdminService {
|
|||||||
let deploySlug = domain.deploySlug;
|
let deploySlug = domain.deploySlug;
|
||||||
let provisionError: string | null = null;
|
let provisionError: string | null = null;
|
||||||
let nextGitRepoUrl = domain.gitRepoUrl;
|
let nextGitRepoUrl = domain.gitRepoUrl;
|
||||||
|
const hostChanged = domain.host !== host;
|
||||||
|
const effectiveGitRepoUrl = gitRepoUrl || domain.gitRepoUrl?.trim() || null;
|
||||||
|
|
||||||
// Only provision when wiring a new/changed repo or when deploy slug is missing.
|
// Only provision when wiring a new/changed repo, deploy slug is missing, or
|
||||||
// Re-saving the same git URL must not block the API on clone/nginx/certbot again.
|
// the apex host changed (nginx vhost is keyed by host — rename must bind the new name).
|
||||||
|
// Re-saving the same git URL + same host must not block the API on clone again.
|
||||||
const alreadyWired =
|
const alreadyWired =
|
||||||
!!gitRepoUrl &&
|
!!effectiveGitRepoUrl &&
|
||||||
!!domain.deploySlug?.trim() &&
|
!!domain.deploySlug?.trim() &&
|
||||||
domain.gitRepoUrl?.trim() === gitRepoUrl;
|
(!gitRepoUrl || domain.gitRepoUrl?.trim() === gitRepoUrl) &&
|
||||||
|
!hostChanged;
|
||||||
|
|
||||||
if (gitRepoUrl && !alreadyWired) {
|
if (effectiveGitRepoUrl && !alreadyWired) {
|
||||||
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
|
const slug = domain.deploySlug?.trim() || deploySlugFromHost(host);
|
||||||
if (!slug) {
|
if (!slug) {
|
||||||
throw new BadRequestException('Could not derive deploy slug from host');
|
throw new BadRequestException('Could not derive deploy slug from host');
|
||||||
@@ -632,10 +813,10 @@ export class BusinessAdminService {
|
|||||||
await this.websiteDeployAgent.provision({
|
await this.websiteDeployAgent.provision({
|
||||||
slug,
|
slug,
|
||||||
host,
|
host,
|
||||||
gitRepoUrl: normalizeGitRepoUrlForClone(gitRepoUrl),
|
gitRepoUrl: normalizeGitRepoUrlForClone(effectiveGitRepoUrl),
|
||||||
});
|
});
|
||||||
deploySlug = slug;
|
deploySlug = slug;
|
||||||
nextGitRepoUrl = gitRepoUrl;
|
nextGitRepoUrl = effectiveGitRepoUrl;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||||
provisionError = err.message;
|
provisionError = err.message;
|
||||||
@@ -645,7 +826,7 @@ export class BusinessAdminService {
|
|||||||
}
|
}
|
||||||
} else if (alreadyWired) {
|
} else if (alreadyWired) {
|
||||||
deploySlug = domain.deploySlug;
|
deploySlug = domain.deploySlug;
|
||||||
nextGitRepoUrl = gitRepoUrl;
|
nextGitRepoUrl = effectiveGitRepoUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await this.prisma.domain.update({
|
const updated = await this.prisma.domain.update({
|
||||||
@@ -658,7 +839,9 @@ export class BusinessAdminService {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dnsError = dto.updateDns ? await this.applyArvanDns(host) : null;
|
const dnsError = dto.updateDns
|
||||||
|
? await this.applyTenantDns(host, dto.dnsProvider === 'cloudflare' ? 'cloudflare' : 'arvan')
|
||||||
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...updated,
|
...updated,
|
||||||
@@ -667,9 +850,16 @@ export class BusinessAdminService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyArvanDns(host: string): Promise<string | null> {
|
private async applyTenantDns(
|
||||||
|
host: string,
|
||||||
|
provider: 'arvan' | 'cloudflare',
|
||||||
|
): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
|
if (provider === 'cloudflare') {
|
||||||
|
await this.cloudflareDns.ensureTenantRecords(host);
|
||||||
|
} else {
|
||||||
await this.arvanDns.ensureTenantRecords(host);
|
await this.arvanDns.ensureTenantRecords(host);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof HttpException) {
|
if (err instanceof HttpException) {
|
||||||
@@ -683,7 +873,7 @@ export class BusinessAdminService {
|
|||||||
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
if (err && typeof err === 'object' && 'message' in err && typeof err.message === 'string') {
|
||||||
return err.message;
|
return err.message;
|
||||||
}
|
}
|
||||||
return 'Arvan DNS update failed';
|
return provider === 'cloudflare' ? 'Cloudflare DNS update failed' : 'Arvan DNS update failed';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1075,6 +1265,7 @@ export class BusinessAdminService {
|
|||||||
tx: Prisma.TransactionClient,
|
tx: Prisma.TransactionClient,
|
||||||
dto: Pick<
|
dto: Pick<
|
||||||
CreateBusinessDto,
|
CreateBusinessDto,
|
||||||
|
| 'ownerTitleId'
|
||||||
| 'ownerFirstName'
|
| 'ownerFirstName'
|
||||||
| 'ownerLastName'
|
| 'ownerLastName'
|
||||||
| 'ownerFirstNameEn'
|
| 'ownerFirstNameEn'
|
||||||
@@ -1087,26 +1278,49 @@ export class BusinessAdminService {
|
|||||||
where: { cellNumber: dto.ownerCellNumber },
|
where: { cellNumber: dto.ownerCellNumber },
|
||||||
});
|
});
|
||||||
|
|
||||||
// Users can own multiple businesses. If the cell number already exists,
|
let titleId: bigint | null | undefined = undefined;
|
||||||
// leave that user unchanged and only attach the new business as owner.
|
if (dto.ownerTitleId !== undefined) {
|
||||||
if (existing) {
|
const title = await tx.userTitle.findFirst({
|
||||||
if (!existing.isActive) {
|
where: { id: BigInt(dto.ownerTitleId), isActive: true },
|
||||||
throw new BadRequestException('Owner user is inactive');
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!title) {
|
||||||
|
throw new BadRequestException('Owner title not found');
|
||||||
}
|
}
|
||||||
return existing;
|
titleId = title.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
|
const passwordHash = await bcrypt.hash(dto.ownerPassword, 10);
|
||||||
|
const nameData = {
|
||||||
return tx.user.create({
|
...(titleId !== undefined ? { titleId } : {}),
|
||||||
data: {
|
|
||||||
cellNumber: dto.ownerCellNumber,
|
|
||||||
passwordHash,
|
|
||||||
firstName: dto.ownerFirstName.trim(),
|
firstName: dto.ownerFirstName.trim(),
|
||||||
lastName: dto.ownerLastName.trim(),
|
lastName: dto.ownerLastName.trim(),
|
||||||
firstNameEn: dto.ownerFirstNameEn.trim(),
|
firstNameEn: dto.ownerFirstNameEn.trim(),
|
||||||
lastNameEn: dto.ownerLastNameEn.trim(),
|
lastNameEn: dto.ownerLastNameEn.trim(),
|
||||||
|
passwordHash,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Users can own multiple businesses. If the cell number already exists,
|
||||||
|
// refresh name + password from the create form, then attach as owner.
|
||||||
|
// Admin-created owners are trusted — always mark the cell verified.
|
||||||
|
if (existing) {
|
||||||
|
if (!existing.isActive) {
|
||||||
|
throw new BadRequestException('Owner user is inactive');
|
||||||
|
}
|
||||||
|
return tx.user.update({
|
||||||
|
where: { id: existing.id },
|
||||||
|
data: {
|
||||||
|
...nameData,
|
||||||
|
cellVerifiedAt: existing.cellVerifiedAt ?? new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return tx.user.create({
|
||||||
|
data: {
|
||||||
|
cellNumber: dto.ownerCellNumber,
|
||||||
cellVerifiedAt: new Date(),
|
cellVerifiedAt: new Date(),
|
||||||
|
...nameData,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1165,6 +1379,11 @@ export class BusinessAdminService {
|
|||||||
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
|
data: { userId: ownerUserId, roleId: businessOwnerRole.id },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tx.user.update({
|
||||||
|
where: { id: ownerUserId },
|
||||||
|
data: { cellVerifiedAt: new Date() },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private serializeBusiness(
|
private serializeBusiness(
|
||||||
@@ -1176,8 +1395,17 @@ export class BusinessAdminService {
|
|||||||
slug: string;
|
slug: string;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
oldBusinessId: bigint | null;
|
oldBusinessId: bigint | null;
|
||||||
|
annualRenewalAt: Date | null;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
updatedAt: Date;
|
updatedAt: Date;
|
||||||
|
settings: unknown;
|
||||||
|
smsBalance?: number;
|
||||||
|
smsSentCount?: number;
|
||||||
|
smsSentMass?: number;
|
||||||
|
smsSentSingular?: number;
|
||||||
|
smsSentAuth?: number;
|
||||||
|
smsSentAutoCustomer?: number;
|
||||||
|
smsSentAdmin?: number;
|
||||||
categoryAssignments: {
|
categoryAssignments: {
|
||||||
category: {
|
category: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
@@ -1204,9 +1432,14 @@ export class BusinessAdminService {
|
|||||||
isVerified: boolean;
|
isVerified: boolean;
|
||||||
sslEnabled: 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 owner = business.businessUsers[0]?.user ?? null;
|
||||||
|
const settings = normalizeBusinessSettings(business.settings);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: business.id,
|
id: business.id,
|
||||||
@@ -1217,8 +1450,23 @@ export class BusinessAdminService {
|
|||||||
isActive: business.isActive,
|
isActive: business.isActive,
|
||||||
oldBusinessId:
|
oldBusinessId:
|
||||||
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
business.oldBusinessId != null ? business.oldBusinessId.toString() : null,
|
||||||
|
annualRenewalAt: business.annualRenewalAt,
|
||||||
createdAt: business.createdAt,
|
createdAt: business.createdAt,
|
||||||
updatedAt: business.updatedAt,
|
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) => ({
|
categories: business.categoryAssignments.map((a) => ({
|
||||||
id: a.category.id,
|
id: a.category.id,
|
||||||
name: a.category.name,
|
name: a.category.name,
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
import { Controller, Get, UseGuards } from '@nestjs/common';
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { IsOptional, IsString, MaxLength } from 'class-validator';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
import { BusinessCategoriesService } from './business-categories.service';
|
import { BusinessCategoriesService } from './business-categories.service';
|
||||||
|
import {
|
||||||
|
CreateBusinessCategoryDto,
|
||||||
|
UpdateBusinessCategoryDto,
|
||||||
|
} from './dto/business-category.dto';
|
||||||
|
|
||||||
|
class ListBusinessCategoriesDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
q?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Controller('business-categories')
|
@Controller('business-categories')
|
||||||
export class BusinessCategoriesController {
|
export class BusinessCategoriesController {
|
||||||
@@ -10,7 +32,38 @@ export class BusinessCategoriesController {
|
|||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
list(@CurrentUser() user: AuthUser) {
|
list(
|
||||||
return this.service.list(user);
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Query() query: ListBusinessCategoriesDto,
|
||||||
|
) {
|
||||||
|
return this.service.list(user, query.q);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
create(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Body() dto: CreateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.create(user, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch(':categoryId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
update(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
@Body() dto: UpdateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
return this.service.update(user, categoryId, dto);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':categoryId')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
remove(
|
||||||
|
@CurrentUser() user: AuthUser,
|
||||||
|
@Param('categoryId') categoryId: string,
|
||||||
|
) {
|
||||||
|
return this.service.remove(user, categoryId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,27 @@
|
|||||||
import { Injectable, ForbiddenException } from '@nestjs/common';
|
import {
|
||||||
|
BadRequestException,
|
||||||
|
ForbiddenException,
|
||||||
|
Injectable,
|
||||||
|
NotFoundException,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { Prisma } from '@prisma/client';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PermissionsService } from '../auth/permissions.service';
|
import { PermissionsService } from '../auth/permissions.service';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
import { AuthUser } from '../auth/auth.types';
|
||||||
|
import {
|
||||||
|
CreateBusinessCategoryDto,
|
||||||
|
UpdateBusinessCategoryDto,
|
||||||
|
} from './dto/business-category.dto';
|
||||||
|
|
||||||
|
function slugify(value: string): string {
|
||||||
|
return (
|
||||||
|
value
|
||||||
|
.toLowerCase()
|
||||||
|
.trim()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-+|-+$/g, '') || 'category'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BusinessCategoriesService {
|
export class BusinessCategoriesService {
|
||||||
@@ -10,15 +30,55 @@ export class BusinessCategoriesService {
|
|||||||
private readonly permissions: PermissionsService,
|
private readonly permissions: PermissionsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async list(actor: AuthUser) {
|
async list(actor: AuthUser, q?: string) {
|
||||||
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
await this.assertCanRead(actor);
|
||||||
const canRead =
|
|
||||||
isSuperAdmin ||
|
|
||||||
actor.roles.includes('business_owner') ||
|
|
||||||
actor.roles.includes('business_staff');
|
|
||||||
|
|
||||||
if (!canRead) {
|
const term = q?.trim();
|
||||||
throw new ForbiddenException('Access denied');
|
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({
|
const categories = await this.prisma.businessCategory.findMany({
|
||||||
@@ -28,13 +88,222 @@ export class BusinessCategoriesService {
|
|||||||
id: true,
|
id: true,
|
||||||
parentId: true,
|
parentId: true,
|
||||||
name: true,
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
slug: true,
|
slug: true,
|
||||||
description: true,
|
description: true,
|
||||||
icon: true,
|
icon: true,
|
||||||
sortOrder: true,
|
sortOrder: true,
|
||||||
|
tags: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return { items: categories };
|
return { items: categories };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async create(actor: AuthUser, dto: CreateBusinessCategoryDto) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
|
||||||
|
const parentId = await this.resolveParentId(dto.parentId);
|
||||||
|
const slug = await this.ensureUniqueSlug(dto.slug ?? slugify(dto.name));
|
||||||
|
|
||||||
|
const created = await this.prisma.businessCategory.create({
|
||||||
|
data: {
|
||||||
|
parentId,
|
||||||
|
name: dto.name.trim(),
|
||||||
|
nameFa: dto.nameFa?.trim() || null,
|
||||||
|
slug,
|
||||||
|
description: dto.description?.trim() || null,
|
||||||
|
icon: dto.icon?.trim() || null,
|
||||||
|
sortOrder: dto.sortOrder ?? 0,
|
||||||
|
isActive: true,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
icon: true,
|
||||||
|
sortOrder: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item: created };
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(
|
||||||
|
actor: AuthUser,
|
||||||
|
categoryIdRaw: string,
|
||||||
|
dto: UpdateBusinessCategoryDto,
|
||||||
|
) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
const categoryId = this.parseId(categoryIdRaw);
|
||||||
|
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: categoryId, isActive: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
let parentId: bigint | null | undefined = undefined;
|
||||||
|
if (dto.parentId !== undefined) {
|
||||||
|
if (dto.parentId === null || dto.parentId === '') {
|
||||||
|
parentId = null;
|
||||||
|
} else {
|
||||||
|
parentId = await this.resolveParentId(dto.parentId);
|
||||||
|
if (parentId === categoryId) {
|
||||||
|
throw new BadRequestException('Category cannot be its own parent');
|
||||||
|
}
|
||||||
|
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||||
|
if (parentId && descendantIds.includes(parentId)) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Cannot move category under its own descendant',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let slug = existing.slug;
|
||||||
|
if (dto.slug) {
|
||||||
|
slug = await this.ensureUniqueSlug(dto.slug, categoryId);
|
||||||
|
} else if (dto.name && dto.name !== existing.name) {
|
||||||
|
slug = await this.ensureUniqueSlug(slugify(dto.name), categoryId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await this.prisma.businessCategory.update({
|
||||||
|
where: { id: categoryId },
|
||||||
|
data: {
|
||||||
|
...(dto.name !== undefined ? { name: dto.name.trim() } : {}),
|
||||||
|
...(dto.nameFa !== undefined
|
||||||
|
? { nameFa: dto.nameFa?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.description !== undefined
|
||||||
|
? { description: dto.description?.trim() || null }
|
||||||
|
: {}),
|
||||||
|
...(dto.icon !== undefined ? { icon: dto.icon?.trim() || null } : {}),
|
||||||
|
...(dto.sortOrder !== undefined ? { sortOrder: dto.sortOrder } : {}),
|
||||||
|
...(parentId !== undefined ? { parentId } : {}),
|
||||||
|
slug,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
parentId: true,
|
||||||
|
name: true,
|
||||||
|
nameFa: true,
|
||||||
|
slug: true,
|
||||||
|
description: true,
|
||||||
|
icon: true,
|
||||||
|
sortOrder: true,
|
||||||
|
tags: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { item: updated };
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(actor: AuthUser, categoryIdRaw: string) {
|
||||||
|
await this.assertCanManage(actor);
|
||||||
|
const categoryId = this.parseId(categoryIdRaw);
|
||||||
|
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: categoryId, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException('Category not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const descendantIds = await this.collectDescendantIds(categoryId);
|
||||||
|
|
||||||
|
await this.prisma.businessCategory.updateMany({
|
||||||
|
where: { id: { in: descendantIds } },
|
||||||
|
data: { isActive: false },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
message: 'Category deleted successfully',
|
||||||
|
deletedIds: descendantIds.map((id) => id.toString()),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanRead(actor: AuthUser) {
|
||||||
|
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
||||||
|
const canRead =
|
||||||
|
isSuperAdmin ||
|
||||||
|
actor.roles.includes('business_owner') ||
|
||||||
|
actor.roles.includes('business_staff');
|
||||||
|
|
||||||
|
if (!canRead) {
|
||||||
|
throw new ForbiddenException('Access denied');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async assertCanManage(actor: AuthUser) {
|
||||||
|
const isSuperAdmin = await this.permissions.isSuperAdmin(actor.id);
|
||||||
|
if (!isSuperAdmin) {
|
||||||
|
throw new ForbiddenException('Only super admins can manage categories');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseId(value: string) {
|
||||||
|
if (!/^\d+$/.test(value)) {
|
||||||
|
throw new BadRequestException('Invalid category id');
|
||||||
|
}
|
||||||
|
return BigInt(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async resolveParentId(parentIdRaw?: string | null) {
|
||||||
|
if (parentIdRaw == null || parentIdRaw === '') return null;
|
||||||
|
const parentId = this.parseId(parentIdRaw);
|
||||||
|
const parent = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: { id: parentId, isActive: true },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!parent) {
|
||||||
|
throw new BadRequestException('Parent category not found');
|
||||||
|
}
|
||||||
|
return parent.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async ensureUniqueSlug(base: string, excludeId?: bigint) {
|
||||||
|
let candidate = slugify(base);
|
||||||
|
let suffix = 2;
|
||||||
|
while (true) {
|
||||||
|
const existing = await this.prisma.businessCategory.findFirst({
|
||||||
|
where: {
|
||||||
|
slug: candidate,
|
||||||
|
...(excludeId ? { id: { not: excludeId } } : {}),
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (!existing) return candidate;
|
||||||
|
candidate = `${slugify(base)}-${suffix}`;
|
||||||
|
suffix += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async collectDescendantIds(rootId: bigint): Promise<bigint[]> {
|
||||||
|
const all = await this.prisma.businessCategory.findMany({
|
||||||
|
where: { isActive: true },
|
||||||
|
select: { id: true, parentId: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const result: bigint[] = [rootId];
|
||||||
|
const queue = [rootId];
|
||||||
|
|
||||||
|
while (queue.length > 0) {
|
||||||
|
const current = queue.shift()!;
|
||||||
|
for (const child of all) {
|
||||||
|
if (child.parentId === current && !result.includes(child.id)) {
|
||||||
|
result.push(child.id);
|
||||||
|
queue.push(child.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
|
||||||
|
|
||||||
export class AddDomainDto {
|
export class AddDomainDto {
|
||||||
@@ -10,11 +10,16 @@ export class AddDomainDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isPrimary?: boolean;
|
isPrimary?: boolean;
|
||||||
|
|
||||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
updateDns?: boolean;
|
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. */
|
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Type } from 'class-transformer';
|
||||||
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
Matches,
|
||||||
|
MaxLength,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateBusinessCategoryDto {
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateBusinessCategoryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(2)
|
||||||
|
@MaxLength(255)
|
||||||
|
name?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
nameFa?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_, value) => value !== null && value !== '')
|
||||||
|
@IsString()
|
||||||
|
parentId?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, {
|
||||||
|
message: 'slug must be lowercase letters, numbers, and hyphens',
|
||||||
|
})
|
||||||
|
slug?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(0)
|
||||||
|
sortOrder?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(100)
|
||||||
|
icon?: string | null;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
@@ -36,6 +37,17 @@ export class CreateBusinessDto {
|
|||||||
@IsInt({ each: true })
|
@IsInt({ each: true })
|
||||||
categoryIds!: number[];
|
categoryIds!: number[];
|
||||||
|
|
||||||
|
/** Annual service renewal date (ISO). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
annualRenewalAt?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
ownerTitleId?: number;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
ownerFirstName!: string;
|
ownerFirstName!: string;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Type } from 'class-transformer';
|
import { Type } from 'class-transformer';
|
||||||
import { IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
import { IsIn, IsInt, IsOptional, IsString, Max, Min } from 'class-validator';
|
||||||
|
|
||||||
export class ListBusinessesDto {
|
export class ListBusinessesDto {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@@ -26,5 +26,10 @@ export class ListBusinessesDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
category?: string;
|
category?: string;
|
||||||
|
|
||||||
|
/** `created` (default) or `renewal` (nearest next/overdue renewal first). */
|
||||||
|
@IsOptional()
|
||||||
|
@IsIn(['created', 'renewal'])
|
||||||
|
sort?: 'created' | 'renewal';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ArrayMinSize,
|
ArrayMinSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
|
IsDateString,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsPositive,
|
IsPositive,
|
||||||
IsString,
|
IsString,
|
||||||
Matches,
|
Matches,
|
||||||
|
Min,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateIf,
|
ValidateIf,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
@@ -53,4 +55,31 @@ export class UpdateBusinessDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
@IsPositive()
|
@IsPositive()
|
||||||
oldBusinessId?: number | null;
|
oldBusinessId?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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';
|
import { GIT_REPO_URL_RE } from '../../website-deploy/website-deploy.util';
|
||||||
|
|
||||||
export class UpdateDomainDto {
|
export class UpdateDomainDto {
|
||||||
@@ -6,11 +6,16 @@ export class UpdateDomainDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
host!: string;
|
host!: string;
|
||||||
|
|
||||||
/** When true, upsert Meshkee tenant records in Arvan DNS. */
|
/** When true, upsert Meshkee tenant records (@, www, business, customer, api). */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
updateDns?: boolean;
|
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. */
|
/** HTTPS or SSH git URL — when set, provisions storefront deploy on the websites VM. */
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -19,6 +19,11 @@ import { StorageService } from '../storage/storage.service';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { MigrateFromOldEntity } from './dto/migrate-from-old.dto';
|
import { MigrateFromOldEntity } from './dto/migrate-from-old.dto';
|
||||||
|
|
||||||
|
// Legacy product images are large; Sharp's default cache plus PM2's 512MB cap
|
||||||
|
// was restarting the API mid-migrate (nginx 502, UI "Unable to migrate").
|
||||||
|
sharp.cache(false);
|
||||||
|
sharp.concurrency(1);
|
||||||
|
|
||||||
export type MigrateEntityCounts = {
|
export type MigrateEntityCounts = {
|
||||||
created: number;
|
created: number;
|
||||||
skipped: number;
|
skipped: number;
|
||||||
@@ -1374,7 +1379,7 @@ export class LegacyMigrateService {
|
|||||||
.filter((m) => Number(m.size) > 0)
|
.filter((m) => Number(m.size) > 0)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||||
oldBizId,
|
oldBizId,
|
||||||
oldSlug,
|
oldSlug,
|
||||||
probeMedia,
|
probeMedia,
|
||||||
@@ -1412,7 +1417,7 @@ export class LegacyMigrateService {
|
|||||||
|
|
||||||
for (const media of articleMedia) {
|
for (const media of articleMedia) {
|
||||||
const size = Number(media.size) || 0;
|
const size = Number(media.size) || 0;
|
||||||
if (size <= 0) {
|
if (size <= 0 || !storagePrefix) {
|
||||||
imagesFailed += 1;
|
imagesFailed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1606,7 +1611,7 @@ export class LegacyMigrateService {
|
|||||||
.filter((m) => Number(m.size) > 0)
|
.filter((m) => Number(m.size) > 0)
|
||||||
.slice(0, 12)
|
.slice(0, 12)
|
||||||
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
.map((m) => ({ id: m.id, fileName: m.file_name }));
|
||||||
const storagePrefix = await this.legacySourceS3.resolveBusinessPrefix(
|
const storagePrefix = await this.tryResolveStoragePrefix(
|
||||||
oldBizId,
|
oldBizId,
|
||||||
oldSlug,
|
oldSlug,
|
||||||
probeMedia,
|
probeMedia,
|
||||||
@@ -1643,7 +1648,7 @@ export class LegacyMigrateService {
|
|||||||
|
|
||||||
for (const media of itemMedia) {
|
for (const media of itemMedia) {
|
||||||
const size = Number(media.size) || 0;
|
const size = Number(media.size) || 0;
|
||||||
if (size <= 0) {
|
if (size <= 0 || !storagePrefix) {
|
||||||
imagesFailed += 1;
|
imagesFailed += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -1973,6 +1978,27 @@ export class LegacyMigrateService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async tryResolveStoragePrefix(
|
||||||
|
oldBizId: number,
|
||||||
|
oldSlug: string | null,
|
||||||
|
probeMedia: Array<{ id: number; fileName: string }>,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (!probeMedia.length) return null;
|
||||||
|
try {
|
||||||
|
return await this.legacySourceS3.resolveBusinessPrefix(
|
||||||
|
oldBizId,
|
||||||
|
oldSlug,
|
||||||
|
probeMedia,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : String(err);
|
||||||
|
console.warn(
|
||||||
|
`Legacy migrate: skipping image copy for business ${oldBizId}: ${message}`,
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async copyLegacyMedia(input: {
|
private async copyLegacyMedia(input: {
|
||||||
businessId: bigint;
|
businessId: bigint;
|
||||||
oldMedia: OldMediaRow;
|
oldMedia: OldMediaRow;
|
||||||
@@ -2482,6 +2508,7 @@ export class LegacyMigrateService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
const titleEn =
|
const titleEn =
|
||||||
(row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product';
|
(row.name_en ?? '').trim() || (row.name ?? '').trim() || 'Product';
|
||||||
const nameFa = (row.name ?? '').trim() || null;
|
const nameFa = (row.name ?? '').trim() || null;
|
||||||
@@ -2620,6 +2647,21 @@ export class LegacyMigrateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
created += 1;
|
created += 1;
|
||||||
|
} catch (err) {
|
||||||
|
console.error(
|
||||||
|
`Legacy migrate: product old id ${row.id} failed`,
|
||||||
|
err instanceof Error ? err.message : err,
|
||||||
|
);
|
||||||
|
skipped += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ((index + 1) % 25 === 0) {
|
||||||
|
const rssMb = Math.round(process.memoryUsage().rss / 1024 / 1024);
|
||||||
|
console.log(
|
||||||
|
`Legacy migrate: products ${index + 1}/${products.length} created=${created} skipped=${skipped} rss=${rssMb}MB`,
|
||||||
|
);
|
||||||
|
await new Promise((resolve) => setImmediate(resolve));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -2869,13 +2911,20 @@ export class LegacyMigrateService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Load already-migrated variations to stay idempotent.
|
// Load already-migrated variations to stay idempotent.
|
||||||
|
// Meshkee allows at most one color and one size variation per category.
|
||||||
const existingVariations = await this.prisma.categoryVariation.findMany({
|
const existingVariations = await this.prisma.categoryVariation.findMany({
|
||||||
where: { businessId },
|
where: { businessId },
|
||||||
select: { id: true, categoryId: true, name: true },
|
select: { id: true, categoryId: true, name: true, variationType: true },
|
||||||
});
|
});
|
||||||
const existingVarSet = new Set(
|
const existingVarSet = new Set(
|
||||||
existingVariations.map((v) => `${v.categoryId}:${v.name}`),
|
existingVariations.map((v) => `${v.categoryId}:${v.name}`),
|
||||||
);
|
);
|
||||||
|
const variationIdByType = new Map<string, bigint>();
|
||||||
|
for (const v of existingVariations) {
|
||||||
|
if (v.variationType === 'color' || v.variationType === 'size') {
|
||||||
|
variationIdByType.set(`${v.categoryId}:${v.variationType}`, v.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for (const attr of attributes) {
|
for (const attr of attributes) {
|
||||||
const newCategoryId = oldToNew.get(attr.attributable_id);
|
const newCategoryId = oldToNew.get(attr.attributable_id);
|
||||||
@@ -2883,12 +2932,20 @@ export class LegacyMigrateService {
|
|||||||
|
|
||||||
const variationName = (attr.name ?? '').trim() || 'Variation';
|
const variationName = (attr.name ?? '').trim() || 'Variation';
|
||||||
const varKey = `${newCategoryId}:${variationName}`;
|
const varKey = `${newCategoryId}:${variationName}`;
|
||||||
if (existingVarSet.has(varKey)) continue;
|
|
||||||
|
|
||||||
const variationType = this.legacyAttributeTypeToVariationType(
|
const variationType = this.legacyAttributeTypeToVariationType(
|
||||||
attr.attribute_type,
|
attr.attribute_type,
|
||||||
);
|
);
|
||||||
|
const typeKey =
|
||||||
|
variationType === 'color' || variationType === 'size'
|
||||||
|
? `${newCategoryId}:${variationType}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
let variationId = typeKey ? variationIdByType.get(typeKey) : undefined;
|
||||||
|
|
||||||
|
if (!variationId && existingVarSet.has(varKey)) continue;
|
||||||
|
|
||||||
|
if (!variationId) {
|
||||||
|
try {
|
||||||
const variation = await this.prisma.categoryVariation.create({
|
const variation = await this.prisma.categoryVariation.create({
|
||||||
data: {
|
data: {
|
||||||
businessId,
|
businessId,
|
||||||
@@ -2899,9 +2956,27 @@ export class LegacyMigrateService {
|
|||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
});
|
});
|
||||||
|
variationId = variation.id;
|
||||||
existingVarSet.add(varKey);
|
existingVarSet.add(varKey);
|
||||||
|
if (typeKey) variationIdByType.set(typeKey, variationId);
|
||||||
|
} catch {
|
||||||
|
// Unique: one color/size per category, or custom name already exists.
|
||||||
|
if (typeKey) {
|
||||||
|
const existing = await this.prisma.categoryVariation.findFirst({
|
||||||
|
where: { categoryId: newCategoryId, variationType },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
variationId = existing?.id;
|
||||||
|
if (variationId) variationIdByType.set(typeKey, variationId);
|
||||||
|
}
|
||||||
|
if (!variationId) continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const options = valuesByAttr.get(attr.id) ?? [];
|
const options = valuesByAttr.get(attr.id) ?? [];
|
||||||
|
const existingOptionCount = await this.prisma.categoryVariationOption.count(
|
||||||
|
{ where: { variationId } },
|
||||||
|
);
|
||||||
for (const [idx, opt] of options.entries()) {
|
for (const [idx, opt] of options.entries()) {
|
||||||
const label = (opt.name ?? '').trim() || 'Option';
|
const label = (opt.name ?? '').trim() || 'Option';
|
||||||
const value =
|
const value =
|
||||||
@@ -2912,14 +2987,12 @@ export class LegacyMigrateService {
|
|||||||
try {
|
try {
|
||||||
await this.prisma.categoryVariationOption.create({
|
await this.prisma.categoryVariationOption.create({
|
||||||
data: {
|
data: {
|
||||||
variationId: variation.id,
|
variationId,
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
colorHex:
|
colorHex:
|
||||||
variationType === 'color'
|
variationType === 'color' ? this.guessColorHex(label) : null,
|
||||||
? this.guessColorHex(label)
|
sortOrder: existingOptionCount + idx,
|
||||||
: null,
|
|
||||||
sortOrder: idx,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
@@ -489,14 +489,18 @@ export class LegacyPurgeService {
|
|||||||
id: businessId,
|
id: businessId,
|
||||||
OR: [
|
OR: [
|
||||||
{ logoMediaId: { in: uniqueIds } },
|
{ logoMediaId: { in: uniqueIds } },
|
||||||
|
{ logoDarkMediaId: { in: uniqueIds } },
|
||||||
{ faviconMediaId: { in: uniqueIds } },
|
{ faviconMediaId: { in: uniqueIds } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
select: { logoMediaId: true, faviconMediaId: true },
|
select: { logoMediaId: true, logoDarkMediaId: true, faviconMediaId: true },
|
||||||
});
|
});
|
||||||
if (businessMedia?.logoMediaId != null) {
|
if (businessMedia?.logoMediaId != null) {
|
||||||
stillLinked.add(businessMedia.logoMediaId.toString());
|
stillLinked.add(businessMedia.logoMediaId.toString());
|
||||||
}
|
}
|
||||||
|
if (businessMedia?.logoDarkMediaId != null) {
|
||||||
|
stillLinked.add(businessMedia.logoDarkMediaId.toString());
|
||||||
|
}
|
||||||
if (businessMedia?.faviconMediaId != null) {
|
if (businessMedia?.faviconMediaId != null) {
|
||||||
stillLinked.add(businessMedia.faviconMediaId.toString());
|
stillLinked.add(businessMedia.faviconMediaId.toString());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { AuthUser } from '../auth/auth.types';
|
||||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { BusinessProfileAiService } from './business-profile-ai.service';
|
||||||
import { BusinessProfileService } from './business-profile.service';
|
import { BusinessProfileService } from './business-profile.service';
|
||||||
|
import { FillBusinessProfileFieldByAiDto } from './dto/business-profile-ai.dto';
|
||||||
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
import { UpdateBusinessProfileDto } from './dto/update-business-profile.dto';
|
||||||
|
|
||||||
@Controller('businesses/:businessId/profile')
|
@Controller('businesses/:businessId/profile')
|
||||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||||
export class BusinessProfileController {
|
export class BusinessProfileController {
|
||||||
constructor(private readonly service: BusinessProfileService) {}
|
constructor(
|
||||||
|
private readonly service: BusinessProfileService,
|
||||||
|
private readonly aiService: BusinessProfileAiService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@RequireBusinessPermission('business.read')
|
@RequireBusinessPermission('business.read')
|
||||||
@@ -27,4 +32,14 @@ export class BusinessProfileController {
|
|||||||
) {
|
) {
|
||||||
return this.service.update(businessId, dto, user);
|
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 { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { BusinessProfileAiService } from './business-profile-ai.service';
|
||||||
import { BusinessProfileController } from './business-profile.controller';
|
import { BusinessProfileController } from './business-profile.controller';
|
||||||
import { BusinessProfileService } from './business-profile.service';
|
import { BusinessProfileService } from './business-profile.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [BusinessProfileController],
|
controllers: [BusinessProfileController],
|
||||||
providers: [BusinessProfileService],
|
providers: [BusinessProfileService, BusinessProfileAiService],
|
||||||
})
|
})
|
||||||
export class BusinessProfileModule {}
|
export class BusinessProfileModule {}
|
||||||
|
|||||||
@@ -68,7 +68,9 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: { orderBy: { createdAt: 'asc' } },
|
addresses: { orderBy: { createdAt: 'asc' } },
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
|
bannerMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -99,7 +101,9 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: { orderBy: { createdAt: 'asc' } },
|
addresses: { orderBy: { createdAt: 'asc' } },
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
|
bannerMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!business) {
|
if (!business) {
|
||||||
@@ -140,7 +144,9 @@ export class BusinessProfileService {
|
|||||||
categoryAssignments: true,
|
categoryAssignments: true,
|
||||||
addresses: true,
|
addresses: true,
|
||||||
logoMedia: true,
|
logoMedia: true,
|
||||||
|
logoDarkMedia: true,
|
||||||
faviconMedia: true,
|
faviconMedia: true,
|
||||||
|
bannerMedia: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -156,7 +162,20 @@ export class BusinessProfileService {
|
|||||||
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
await this.assertLogoMedia(businessId, BigInt(dto.logoMediaId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.logoDarkMediaId !== undefined && dto.logoDarkMediaId !== null) {
|
||||||
|
await this.assertLogoMedia(businessId, BigInt(dto.logoDarkMediaId));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.faviconMediaId !== undefined && dto.faviconMediaId !== null) {
|
||||||
|
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 previousFaviconMediaId = business.faviconMediaId;
|
||||||
|
const faviconExplicitlySet = dto.faviconMediaId !== undefined;
|
||||||
const logoChanged =
|
const logoChanged =
|
||||||
dto.logoMediaId !== undefined &&
|
dto.logoMediaId !== undefined &&
|
||||||
(dto.logoMediaId === null
|
(dto.logoMediaId === null
|
||||||
@@ -168,6 +187,7 @@ export class BusinessProfileService {
|
|||||||
|
|
||||||
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
|
if (dto.nameEn !== undefined) data.name = dto.nameEn.trim();
|
||||||
if (dto.nameFa !== undefined) data.nameFa = dto.nameFa.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.about !== undefined) data.about = dto.about.trim() || null;
|
||||||
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
|
if (dto.vision !== undefined) data.vision = dto.vision.trim() || null;
|
||||||
if (dto.emails !== undefined) {
|
if (dto.emails !== undefined) {
|
||||||
@@ -176,7 +196,9 @@ export class BusinessProfileService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (dto.phoneNumbers !== undefined) {
|
if (dto.phoneNumbers !== undefined) {
|
||||||
data.phoneNumbers = toPrismaJsonPhoneNumbers(dto.phoneNumbers);
|
data.phoneNumbers = toPrismaJsonPhoneNumbers(
|
||||||
|
normalizePhoneNumbers(dto.phoneNumbers),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (dto.socialMedia !== undefined) {
|
if (dto.socialMedia !== undefined) {
|
||||||
data.socialMedia = toPrismaJsonSocialMedia({
|
data.socialMedia = toPrismaJsonSocialMedia({
|
||||||
@@ -196,6 +218,27 @@ export class BusinessProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (dto.logoDarkMediaId !== undefined) {
|
||||||
|
data.logoDarkMedia =
|
||||||
|
dto.logoDarkMediaId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { connect: { id: BigInt(dto.logoDarkMediaId) } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (dto.faviconMediaId !== undefined) {
|
||||||
|
data.faviconMedia =
|
||||||
|
dto.faviconMediaId === null
|
||||||
|
? { disconnect: true }
|
||||||
|
: { 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) {
|
if (Object.keys(data).length > 0) {
|
||||||
await tx.business.update({
|
await tx.business.update({
|
||||||
where: { id: businessId },
|
where: { id: businessId },
|
||||||
@@ -220,7 +263,7 @@ export class BusinessProfileService {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (logoChanged) {
|
if (logoChanged && !faviconExplicitlySet) {
|
||||||
if (dto.logoMediaId === null) {
|
if (dto.logoMediaId === null) {
|
||||||
await this.deleteFaviconMedia(previousFaviconMediaId);
|
await this.deleteFaviconMedia(previousFaviconMediaId);
|
||||||
} else if (dto.logoMediaId != null) {
|
} else if (dto.logoMediaId != null) {
|
||||||
@@ -369,6 +412,7 @@ export class BusinessProfileService {
|
|||||||
private serializeProfile(business: {
|
private serializeProfile(business: {
|
||||||
name: string;
|
name: string;
|
||||||
nameFa: string | null;
|
nameFa: string | null;
|
||||||
|
description: string | null;
|
||||||
about: string | null;
|
about: string | null;
|
||||||
vision: string | null;
|
vision: string | null;
|
||||||
emails: unknown;
|
emails: unknown;
|
||||||
@@ -376,13 +420,18 @@ export class BusinessProfileService {
|
|||||||
socialMedia: unknown;
|
socialMedia: unknown;
|
||||||
logoMediaId: bigint | null;
|
logoMediaId: bigint | null;
|
||||||
logoMedia: { publicUrl: string } | null;
|
logoMedia: { publicUrl: string } | null;
|
||||||
|
logoDarkMediaId: bigint | null;
|
||||||
|
logoDarkMedia: { publicUrl: string } | null;
|
||||||
faviconMediaId: bigint | null;
|
faviconMediaId: bigint | null;
|
||||||
faviconMedia: { publicUrl: string } | null;
|
faviconMedia: { publicUrl: string } | null;
|
||||||
|
bannerMediaId: bigint | null;
|
||||||
|
bannerMedia: { publicUrl: string } | null;
|
||||||
categoryAssignments: { categoryId: bigint }[];
|
categoryAssignments: { categoryId: bigint }[];
|
||||||
}): BusinessProfile {
|
}): BusinessProfile {
|
||||||
return {
|
return {
|
||||||
nameEn: business.name,
|
nameEn: business.name,
|
||||||
nameFa: business.nameFa ?? '',
|
nameFa: business.nameFa ?? '',
|
||||||
|
title: business.description ?? '',
|
||||||
about: business.about ?? '',
|
about: business.about ?? '',
|
||||||
vision: business.vision ?? '',
|
vision: business.vision ?? '',
|
||||||
emails: normalizeEmails(business.emails),
|
emails: normalizeEmails(business.emails),
|
||||||
@@ -390,11 +439,15 @@ export class BusinessProfileService {
|
|||||||
socialMedia: normalizeSocialMedia(business.socialMedia),
|
socialMedia: normalizeSocialMedia(business.socialMedia),
|
||||||
logoMediaId: business.logoMediaId?.toString() ?? null,
|
logoMediaId: business.logoMediaId?.toString() ?? null,
|
||||||
logoUrl: business.logoMedia?.publicUrl ?? null,
|
logoUrl: business.logoMedia?.publicUrl ?? null,
|
||||||
|
logoDarkMediaId: business.logoDarkMediaId?.toString() ?? null,
|
||||||
|
logoDarkUrl: business.logoDarkMedia?.publicUrl ?? null,
|
||||||
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
faviconMediaId: business.faviconMediaId?.toString() ?? null,
|
||||||
faviconUrl:
|
faviconUrl:
|
||||||
business.faviconMedia?.publicUrl ??
|
business.faviconMedia?.publicUrl ??
|
||||||
business.logoMedia?.publicUrl ??
|
business.logoMedia?.publicUrl ??
|
||||||
null,
|
null,
|
||||||
|
bannerMediaId: business.bannerMediaId?.toString() ?? null,
|
||||||
|
bannerUrl: business.bannerMedia?.publicUrl ?? null,
|
||||||
categoryIds: business.categoryAssignments.map((item) =>
|
categoryIds: business.categoryAssignments.map((item) =>
|
||||||
item.categoryId.toString(),
|
item.categoryId.toString(),
|
||||||
),
|
),
|
||||||
@@ -403,6 +456,7 @@ export class BusinessProfileService {
|
|||||||
|
|
||||||
private serializeAddress(address: {
|
private serializeAddress(address: {
|
||||||
id: bigint;
|
id: bigint;
|
||||||
|
label: string | null;
|
||||||
province: string;
|
province: string;
|
||||||
city: string;
|
city: string;
|
||||||
address: string;
|
address: string;
|
||||||
@@ -411,6 +465,7 @@ export class BusinessProfileService {
|
|||||||
}): BusinessAddress {
|
}): BusinessAddress {
|
||||||
return {
|
return {
|
||||||
id: address.id.toString(),
|
id: address.id.toString(),
|
||||||
|
name: address.label ?? '',
|
||||||
province: address.province,
|
province: address.province,
|
||||||
city: address.city,
|
city: address.city,
|
||||||
address: address.address,
|
address: address.address,
|
||||||
@@ -435,10 +490,11 @@ export class BusinessProfileService {
|
|||||||
|
|
||||||
for (const item of addresses) {
|
for (const item of addresses) {
|
||||||
const payload = {
|
const payload = {
|
||||||
|
label: item.name?.trim() || null,
|
||||||
province: item.province.trim(),
|
province: item.province.trim(),
|
||||||
city: item.city.trim(),
|
city: item.city.trim(),
|
||||||
address: item.address.trim(),
|
address: item.address.trim(),
|
||||||
postalCode: item.postalCode.trim(),
|
postalCode: item.postalCode?.trim() || null,
|
||||||
landline: item.landline?.trim() || null,
|
landline: item.landline?.trim() || null,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -493,6 +549,28 @@ export class BusinessProfileService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Recommended banner aspect 18:6 (3:1). Soft-check when dimensions exist. */
|
||||||
|
private async assertBannerMedia(businessId: bigint, mediaId: bigint) {
|
||||||
|
const media = await this.prisma.media.findFirst({
|
||||||
|
where: { id: mediaId, businessId },
|
||||||
|
select: { id: true, width: true, height: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!media) {
|
||||||
|
throw new BadRequestException('Banner media not found for this business');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (media.width && media.height && media.height > 0) {
|
||||||
|
const ratio = media.width / media.height;
|
||||||
|
const target = 18 / 6; // 3
|
||||||
|
if (Math.abs(ratio - target) / target > 0.12) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
'Banner aspect ratio should be about 18:6 (3:1)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async assertPermission(
|
private async assertPermission(
|
||||||
businessId: bigint,
|
businessId: bigint,
|
||||||
userId: bigint,
|
userId: bigint,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
export type BusinessPhoneType = 'landline' | 'cell';
|
export type BusinessPhoneType = 'landline' | 'cell';
|
||||||
|
|
||||||
export type BusinessPhoneNumber = {
|
export type BusinessPhoneNumber = {
|
||||||
|
/** Display label — presets like Cell/Landline or any custom text */
|
||||||
|
label: string;
|
||||||
type: BusinessPhoneType;
|
type: BusinessPhoneType;
|
||||||
number: string;
|
number: string;
|
||||||
};
|
};
|
||||||
@@ -26,6 +28,8 @@ export const DEFAULT_BUSINESS_SOCIAL_MEDIA: BusinessSocialMedia = {
|
|||||||
export type BusinessProfile = {
|
export type BusinessProfile = {
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
nameFa: string;
|
nameFa: string;
|
||||||
|
/** Single-line business title / tagline (stored as businesses.description). */
|
||||||
|
title: string;
|
||||||
about: string;
|
about: string;
|
||||||
vision: string;
|
vision: string;
|
||||||
emails: string[];
|
emails: string[];
|
||||||
@@ -33,13 +37,19 @@ export type BusinessProfile = {
|
|||||||
socialMedia: BusinessSocialMedia;
|
socialMedia: BusinessSocialMedia;
|
||||||
logoMediaId: string | null;
|
logoMediaId: string | null;
|
||||||
logoUrl: string | null;
|
logoUrl: string | null;
|
||||||
|
logoDarkMediaId: string | null;
|
||||||
|
logoDarkUrl: string | null;
|
||||||
faviconMediaId: string | null;
|
faviconMediaId: string | null;
|
||||||
faviconUrl: string | null;
|
faviconUrl: string | null;
|
||||||
|
/** Recommended aspect ratio 18:6 (3:1). */
|
||||||
|
bannerMediaId: string | null;
|
||||||
|
bannerUrl: string | null;
|
||||||
categoryIds: string[];
|
categoryIds: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type BusinessAddress = {
|
export type BusinessAddress = {
|
||||||
id: string;
|
id: string;
|
||||||
|
name: string;
|
||||||
province: string;
|
province: string;
|
||||||
city: string;
|
city: string;
|
||||||
address: string;
|
address: string;
|
||||||
|
|||||||
@@ -25,10 +25,17 @@ export function normalizePhoneNumbers(raw: unknown): BusinessPhoneNumber[] {
|
|||||||
|
|
||||||
return raw
|
return raw
|
||||||
.filter(isRecord)
|
.filter(isRecord)
|
||||||
.map((item) => ({
|
.map((item) => {
|
||||||
type: (item.type === 'landline' ? 'landline' : 'cell') as BusinessPhoneNumber['type'],
|
const type = (
|
||||||
number: readString(item.number).trim(),
|
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);
|
.filter((item) => item.number.length > 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,7 +57,15 @@ export function toPrismaJsonEmails(emails: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function toPrismaJsonPhoneNumbers(phoneNumbers: BusinessPhoneNumber[]) {
|
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) {
|
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,
|
IsInt,
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
|
MaxLength,
|
||||||
MinLength,
|
MinLength,
|
||||||
ValidateNested,
|
ValidateNested,
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
class BusinessPhoneNumberDto {
|
class BusinessPhoneNumberDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
label?: string;
|
||||||
|
|
||||||
@IsIn(['landline', 'cell'])
|
@IsIn(['landline', 'cell'])
|
||||||
type!: 'landline' | 'cell';
|
type!: 'landline' | 'cell';
|
||||||
|
|
||||||
@@ -49,6 +55,10 @@ class BusinessAddressDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
name?: string | null;
|
||||||
|
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
province!: string;
|
province!: string;
|
||||||
@@ -61,9 +71,10 @@ class BusinessAddressDto {
|
|||||||
@MinLength(1)
|
@MinLength(1)
|
||||||
address!: string;
|
address!: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MinLength(1)
|
@MaxLength(20)
|
||||||
postalCode!: string;
|
postalCode?: string | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -81,6 +92,12 @@ export class UpdateBusinessProfileDto {
|
|||||||
@MinLength(2)
|
@MinLength(2)
|
||||||
nameFa?: string;
|
nameFa?: string;
|
||||||
|
|
||||||
|
/** Single-line business title / tagline. */
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(255)
|
||||||
|
title?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
about?: string;
|
about?: string;
|
||||||
@@ -110,6 +127,22 @@ export class UpdateBusinessProfileDto {
|
|||||||
@IsInt()
|
@IsInt()
|
||||||
logoMediaId?: number | null;
|
logoMediaId?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
logoDarkMediaId?: number | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
faviconMediaId?: number | null;
|
||||||
|
|
||||||
|
/** Marketplace/storefront banner; recommended aspect 18:6 (3:1). */
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
bannerMediaId?: number | null;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user