Compare commits
No commits in common. "ai-dev" and "master" have entirely different histories.
34
.env.example
34
.env.example
@ -1,34 +0,0 @@
|
|||||||
# ---------------------------------------------------------------------------
|
|
||||||
# External database / Supabase (recommended: Supabase transaction pooler URL)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
DATABASE_URL=postgresql://postgres:<password>@<host>:6543/postgres?sslmode=require
|
|
||||||
DB_SSL=require
|
|
||||||
DB_SSL_REJECT_UNAUTHORIZED=false
|
|
||||||
DB_POOL_MAX=20
|
|
||||||
DB_CONNECTION_TIMEOUT_MS=10000
|
|
||||||
DB_IDLE_TIMEOUT_MS=30000
|
|
||||||
DB_QUERY_TIMEOUT_MS=15000
|
|
||||||
DB_STATEMENT_TIMEOUT_MS=15000
|
|
||||||
DB_APP_NAME=flatlogic-backend
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# API server / security
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
PORT=8080
|
|
||||||
ADMIN_TOKEN=change-me-admin-token
|
|
||||||
API_INGEST_KEY=change-me-ingest-key
|
|
||||||
WEBHOOK_SECRET=change-me-global-webhook-secret
|
|
||||||
SHEIN_WEBHOOK_SECRET=change-me-shein-webhook-secret
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Optional Supabase SDK keys (if later needed by background jobs)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
SUPABASE_URL=https://<project-ref>.supabase.co
|
|
||||||
SUPABASE_ANON_KEY=<anon-key>
|
|
||||||
SUPABASE_SERVICE_ROLE_KEY=<service-role-key>
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Frontend
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
VITE_API_BASE_URL=/api
|
|
||||||
API_SERVER_URL=http://127.0.0.1:8080
|
|
||||||
42
.github/workflows/ci.yml
vendored
42
.github/workflows/ci.yml
vendored
@ -1,42 +0,0 @@
|
|||||||
name: CI
|
|
||||||
|
|
||||||
on:
|
|
||||||
pull_request:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
- develop
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: ci-${{ github.ref }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-check:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 20
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.16.1 --activate
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Typecheck workspace
|
|
||||||
run: pnpm typecheck
|
|
||||||
|
|
||||||
- name: Build workspace
|
|
||||||
run: pnpm build
|
|
||||||
83
.github/workflows/deploy-flatlogic.yml
vendored
83
.github/workflows/deploy-flatlogic.yml
vendored
@ -1,83 +0,0 @@
|
|||||||
name: Deploy to Flatlogic VM
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- master
|
|
||||||
workflow_dispatch:
|
|
||||||
repository_dispatch:
|
|
||||||
types:
|
|
||||||
- bolt_sync
|
|
||||||
- replit_sync
|
|
||||||
- flatlogic_deploy
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: flatlogic-deploy-${{ github.ref_name }}
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
timeout-minutes: 30
|
|
||||||
environment: production
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: Checkout repository
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Setup Node.js
|
|
||||||
uses: actions/setup-node@v4
|
|
||||||
with:
|
|
||||||
node-version: 20
|
|
||||||
cache: pnpm
|
|
||||||
|
|
||||||
- name: Enable Corepack
|
|
||||||
run: |
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.16.1 --activate
|
|
||||||
|
|
||||||
- name: Install dependencies
|
|
||||||
run: pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
- name: Typecheck workspace
|
|
||||||
run: pnpm typecheck
|
|
||||||
|
|
||||||
- name: Build workspace
|
|
||||||
run: pnpm build
|
|
||||||
|
|
||||||
- name: Configure SSH
|
|
||||||
env:
|
|
||||||
FLATLOGIC_SSH_KEY: ${{ secrets.FLATLOGIC_SSH_KEY }}
|
|
||||||
FLATLOGIC_HOST: ${{ secrets.FLATLOGIC_HOST }}
|
|
||||||
run: |
|
|
||||||
test -n "$FLATLOGIC_SSH_KEY"
|
|
||||||
test -n "$FLATLOGIC_HOST"
|
|
||||||
install -m 700 -d ~/.ssh
|
|
||||||
printf '%s' "$FLATLOGIC_SSH_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan -H "$FLATLOGIC_HOST" >> ~/.ssh/known_hosts
|
|
||||||
|
|
||||||
- name: Deploy on Flatlogic VM
|
|
||||||
env:
|
|
||||||
FLATLOGIC_HOST: ${{ secrets.FLATLOGIC_HOST }}
|
|
||||||
FLATLOGIC_USER: ${{ secrets.FLATLOGIC_USER }}
|
|
||||||
PROJECT_DIR: ${{ secrets.FLATLOGIC_PROJECT_DIR }}
|
|
||||||
DEPLOY_BRANCH: ${{ secrets.FLATLOGIC_DEPLOY_BRANCH }}
|
|
||||||
DATABASE_URL: ${{ secrets.DATABASE_URL }}
|
|
||||||
DB_SSL: ${{ secrets.DB_SSL }}
|
|
||||||
DB_POOL_MAX: ${{ secrets.DB_POOL_MAX }}
|
|
||||||
DB_QUERY_TIMEOUT_MS: ${{ secrets.DB_QUERY_TIMEOUT_MS }}
|
|
||||||
DB_STATEMENT_TIMEOUT_MS: ${{ secrets.DB_STATEMENT_TIMEOUT_MS }}
|
|
||||||
ADMIN_TOKEN: ${{ secrets.ADMIN_TOKEN }}
|
|
||||||
API_INGEST_KEY: ${{ secrets.API_INGEST_KEY }}
|
|
||||||
WEBHOOK_SECRET: ${{ secrets.WEBHOOK_SECRET }}
|
|
||||||
SHEIN_WEBHOOK_SECRET: ${{ secrets.SHEIN_WEBHOOK_SECRET }}
|
|
||||||
API_PORT: ${{ secrets.API_PORT }}
|
|
||||||
STORE_PORT: ${{ secrets.STORE_PORT }}
|
|
||||||
run: |
|
|
||||||
test -n "$FLATLOGIC_HOST"
|
|
||||||
test -n "$FLATLOGIC_USER"
|
|
||||||
test -n "$PROJECT_DIR"
|
|
||||||
ssh "$FLATLOGIC_USER@$FLATLOGIC_HOST" \
|
|
||||||
"export PROJECT_DIR='$PROJECT_DIR' DEPLOY_BRANCH='${DEPLOY_BRANCH:-${GITHUB_REF_NAME}}' DATABASE_URL='$DATABASE_URL' DB_SSL='${DB_SSL:-require}' DB_POOL_MAX='${DB_POOL_MAX:-20}' DB_QUERY_TIMEOUT_MS='${DB_QUERY_TIMEOUT_MS:-15000}' DB_STATEMENT_TIMEOUT_MS='${DB_STATEMENT_TIMEOUT_MS:-15000}' ADMIN_TOKEN='$ADMIN_TOKEN' API_INGEST_KEY='$API_INGEST_KEY' WEBHOOK_SECRET='$WEBHOOK_SECRET' SHEIN_WEBHOOK_SECRET='$SHEIN_WEBHOOK_SECRET' API_PORT='${API_PORT:-8080}' STORE_PORT='${STORE_PORT:-3001}' && bash '$PROJECT_DIR/scripts/flatlogic-deploy.sh'"
|
|
||||||
@ -6,12 +6,6 @@ import { logger } from "./lib/logger";
|
|||||||
|
|
||||||
const app: Express = express();
|
const app: Express = express();
|
||||||
|
|
||||||
function captureRawBody(req: express.Request, _res: express.Response, buf: Buffer): void {
|
|
||||||
if (buf.length > 0) {
|
|
||||||
req.rawBody = buf.toString("utf8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
pinoHttp({
|
pinoHttp({
|
||||||
logger,
|
logger,
|
||||||
@ -32,8 +26,8 @@ app.use(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
app.use(express.json({ verify: captureRawBody }));
|
app.use(express.json());
|
||||||
app.use(express.urlencoded({ extended: true, verify: captureRawBody }));
|
app.use(express.urlencoded({ extended: true }));
|
||||||
|
|
||||||
app.use("/api", router);
|
app.use("/api", router);
|
||||||
|
|
||||||
|
|||||||
@ -1,423 +0,0 @@
|
|||||||
import { db, categoriesTable, integrationEventsTable, productsTable, type ProductVariant } from "@workspace/db";
|
|
||||||
import { and, desc, eq, sql } from "drizzle-orm";
|
|
||||||
|
|
||||||
export type SourceName = "extra" | "shein";
|
|
||||||
|
|
||||||
type CategoryInput = {
|
|
||||||
id?: number;
|
|
||||||
name?: string;
|
|
||||||
name_en?: string;
|
|
||||||
slug?: string;
|
|
||||||
parent_slug?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type ProductIngestInput = {
|
|
||||||
source: SourceName;
|
|
||||||
external_id?: string;
|
|
||||||
sku?: string;
|
|
||||||
source_url?: string;
|
|
||||||
currency?: string;
|
|
||||||
availability?: string;
|
|
||||||
name?: string;
|
|
||||||
name_en?: string;
|
|
||||||
short_description?: string;
|
|
||||||
description?: string;
|
|
||||||
brand?: string;
|
|
||||||
subcategory?: string;
|
|
||||||
category_id?: number;
|
|
||||||
category?: CategoryInput;
|
|
||||||
price?: number;
|
|
||||||
original_price?: number;
|
|
||||||
images?: string[];
|
|
||||||
sizes?: string[];
|
|
||||||
colors?: string[];
|
|
||||||
specs?: Record<string, string>;
|
|
||||||
marketing_points?: string[];
|
|
||||||
variants?: ProductVariant[];
|
|
||||||
tags?: string[];
|
|
||||||
metadata?: Record<string, unknown>;
|
|
||||||
stock?: number;
|
|
||||||
rating?: number;
|
|
||||||
review_count?: number;
|
|
||||||
is_trending?: boolean;
|
|
||||||
is_bestseller?: boolean;
|
|
||||||
is_new?: boolean;
|
|
||||||
is_top_rated?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type WebhookProductPatch = Partial<ProductIngestInput> & {
|
|
||||||
source?: SourceName;
|
|
||||||
external_id?: string;
|
|
||||||
sku?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function asString(value: unknown): string | undefined {
|
|
||||||
if (typeof value !== "string") return undefined;
|
|
||||||
const normalized = value.trim();
|
|
||||||
return normalized.length > 0 ? normalized : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asNumber(value: unknown): number | undefined {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
||||||
if (typeof value === "string" && value.trim() !== "") {
|
|
||||||
const parsed = Number(value);
|
|
||||||
return Number.isFinite(parsed) ? parsed : undefined;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asInteger(value: unknown): number | undefined {
|
|
||||||
const parsed = asNumber(value);
|
|
||||||
return parsed === undefined ? undefined : Math.max(0, Math.trunc(parsed));
|
|
||||||
}
|
|
||||||
|
|
||||||
function asBoolean(value: unknown, fallback = false): boolean {
|
|
||||||
if (typeof value === "boolean") return value;
|
|
||||||
if (typeof value === "string") {
|
|
||||||
if (["true", "1", "yes", "on"].includes(value.toLowerCase())) return true;
|
|
||||||
if (["false", "0", "no", "off"].includes(value.toLowerCase())) return false;
|
|
||||||
}
|
|
||||||
return fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringArray(value: unknown): string[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
return value
|
|
||||||
.map((entry) => asString(entry))
|
|
||||||
.filter((entry): entry is string => Boolean(entry));
|
|
||||||
}
|
|
||||||
|
|
||||||
function asStringRecord(value: unknown): Record<string, string> {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
||||||
const entries = Object.entries(value as Record<string, unknown>)
|
|
||||||
.map(([key, entryValue]) => {
|
|
||||||
const normalizedKey = asString(key);
|
|
||||||
const normalizedValue = asString(entryValue);
|
|
||||||
return normalizedKey && normalizedValue ? [normalizedKey, normalizedValue] : null;
|
|
||||||
})
|
|
||||||
.filter((entry): entry is [string, string] => Boolean(entry));
|
|
||||||
return Object.fromEntries(entries);
|
|
||||||
}
|
|
||||||
|
|
||||||
function asJsonRecord(value: unknown): Record<string, unknown> {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
|
||||||
return value as Record<string, unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function asVariants(value: unknown): ProductVariant[] {
|
|
||||||
if (!Array.isArray(value)) return [];
|
|
||||||
|
|
||||||
const variants: ProductVariant[] = [];
|
|
||||||
|
|
||||||
for (const entry of value) {
|
|
||||||
if (!entry || typeof entry !== "object") continue;
|
|
||||||
const raw = entry as Record<string, unknown>;
|
|
||||||
const label = asString(raw["label"]);
|
|
||||||
const price = asNumber(raw["price"]);
|
|
||||||
if (!label || price === undefined) continue;
|
|
||||||
const original = asNumber(raw["original_price"]);
|
|
||||||
variants.push({
|
|
||||||
label,
|
|
||||||
price: toMoney(price),
|
|
||||||
original_price: original === undefined ? undefined : toMoney(original),
|
|
||||||
sku: asString(raw["sku"]),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return variants;
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMoney(value: number): string {
|
|
||||||
return value.toFixed(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
function slugify(value: string): string {
|
|
||||||
return value
|
|
||||||
.toLowerCase()
|
|
||||||
.normalize("NFKD")
|
|
||||||
.replace(/[^a-z0-9\s-]/g, "")
|
|
||||||
.trim()
|
|
||||||
.replace(/\s+/g, "-")
|
|
||||||
.replace(/-+/g, "-")
|
|
||||||
.replace(/^-|-$/g, "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSource(value: unknown, fallback: SourceName): SourceName {
|
|
||||||
return value === "shein" ? "shein" : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeCategory(value: unknown): CategoryInput | undefined {
|
|
||||||
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
||||||
const raw = value as Record<string, unknown>;
|
|
||||||
return {
|
|
||||||
id: asInteger(raw["id"]),
|
|
||||||
name: asString(raw["name"]),
|
|
||||||
name_en: asString(raw["name_en"]),
|
|
||||||
slug: asString(raw["slug"]),
|
|
||||||
parent_slug: asString(raw["parent_slug"]),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeProductInput(raw: unknown, fallbackSource: SourceName = "extra"): ProductIngestInput {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
throw new Error("Each product payload must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
const input = raw as Record<string, unknown>;
|
|
||||||
const source = resolveSource(input["source"], fallbackSource);
|
|
||||||
const product: ProductIngestInput = {
|
|
||||||
source,
|
|
||||||
external_id: asString(input["external_id"]),
|
|
||||||
sku: asString(input["sku"]),
|
|
||||||
source_url: asString(input["source_url"]),
|
|
||||||
currency: asString(input["currency"]) ?? "SAR",
|
|
||||||
availability: asString(input["availability"]) ?? "unknown",
|
|
||||||
name: asString(input["name"]),
|
|
||||||
name_en: asString(input["name_en"]),
|
|
||||||
short_description: asString(input["short_description"]),
|
|
||||||
description: asString(input["description"]),
|
|
||||||
brand: asString(input["brand"]),
|
|
||||||
subcategory: asString(input["subcategory"]),
|
|
||||||
category_id: asInteger(input["category_id"]),
|
|
||||||
category: normalizeCategory(input["category"]),
|
|
||||||
price: asNumber(input["price"]),
|
|
||||||
original_price: asNumber(input["original_price"]),
|
|
||||||
images: asStringArray(input["images"]),
|
|
||||||
sizes: asStringArray(input["sizes"]),
|
|
||||||
colors: asStringArray(input["colors"]),
|
|
||||||
specs: asStringRecord(input["specs"]),
|
|
||||||
marketing_points: asStringArray(input["marketing_points"]),
|
|
||||||
variants: asVariants(input["variants"]),
|
|
||||||
tags: asStringArray(input["tags"]),
|
|
||||||
metadata: asJsonRecord(input["metadata"]),
|
|
||||||
stock: asInteger(input["stock"]),
|
|
||||||
rating: asNumber(input["rating"]),
|
|
||||||
review_count: asInteger(input["review_count"]),
|
|
||||||
is_trending: asBoolean(input["is_trending"]),
|
|
||||||
is_bestseller: asBoolean(input["is_bestseller"]),
|
|
||||||
is_new: asBoolean(input["is_new"], true),
|
|
||||||
is_top_rated: asBoolean(input["is_top_rated"]),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!product.external_id && !product.sku) {
|
|
||||||
throw new Error("Product payload must include external_id or sku");
|
|
||||||
}
|
|
||||||
|
|
||||||
return product;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeWebhookPatch(raw: unknown, fallbackSource: SourceName = "shein"): WebhookProductPatch {
|
|
||||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
|
|
||||||
throw new Error("Each webhook item must be an object");
|
|
||||||
}
|
|
||||||
|
|
||||||
const patch = normalizeProductInput(raw, fallbackSource);
|
|
||||||
return patch;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function ensureCategory(source: SourceName, category?: CategoryInput, categoryId?: number): Promise<number> {
|
|
||||||
if (categoryId) {
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: categoriesTable.id })
|
|
||||||
.from(categoriesTable)
|
|
||||||
.where(eq(categoriesTable.id, categoryId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing[0]) return existing[0].id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fallbackSlug = `uncategorized-${source}`;
|
|
||||||
const slug = category?.slug ?? (category?.name ? slugify(category.name) : fallbackSlug);
|
|
||||||
const name = category?.name ?? (source === "shein" ? "شي إن" : "إكسترا");
|
|
||||||
|
|
||||||
const existing = await db
|
|
||||||
.select({ id: categoriesTable.id })
|
|
||||||
.from(categoriesTable)
|
|
||||||
.where(and(eq(categoriesTable.source, source), eq(categoriesTable.slug, slug)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing[0]) return existing[0].id;
|
|
||||||
|
|
||||||
const [inserted] = await db
|
|
||||||
.insert(categoriesTable)
|
|
||||||
.values({
|
|
||||||
name,
|
|
||||||
name_en: category?.name_en ?? name,
|
|
||||||
slug,
|
|
||||||
source,
|
|
||||||
sort_order: 0,
|
|
||||||
})
|
|
||||||
.returning({ id: categoriesTable.id });
|
|
||||||
|
|
||||||
if (!inserted) {
|
|
||||||
throw new Error("Failed to create category for ingested product");
|
|
||||||
}
|
|
||||||
|
|
||||||
return inserted.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findExistingProduct(source: SourceName, externalId?: string, sku?: string) {
|
|
||||||
if (externalId) {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(productsTable)
|
|
||||||
.where(and(eq(productsTable.source, source), eq(productsTable.external_id, externalId)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (rows[0]) return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sku) {
|
|
||||||
const rows = await db
|
|
||||||
.select()
|
|
||||||
.from(productsTable)
|
|
||||||
.where(and(eq(productsTable.source, source), eq(productsTable.sku, sku)))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (rows[0]) return rows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function upsertExternalProduct(input: ProductIngestInput) {
|
|
||||||
const existing = await findExistingProduct(input.source, input.external_id, input.sku);
|
|
||||||
const categoryId = await ensureCategory(input.source, input.category, input.category_id ?? existing?.category_id);
|
|
||||||
const productCode = input.external_id ?? input.sku ?? String(Date.now());
|
|
||||||
const resolvedName = input.name ?? existing?.name ?? `${input.source.toUpperCase()} ${productCode}`;
|
|
||||||
const resolvedPrice = input.price ?? (existing?.price ? Number(existing.price) : 0);
|
|
||||||
const resolvedRating = input.rating ?? (existing?.rating ? Number(existing.rating) : 0);
|
|
||||||
const resolvedReviewCount = input.review_count ?? existing?.review_count ?? 0;
|
|
||||||
const resolvedStock = input.stock ?? existing?.stock ?? 0;
|
|
||||||
|
|
||||||
const values = {
|
|
||||||
source: input.source,
|
|
||||||
external_id: input.external_id ?? existing?.external_id ?? null,
|
|
||||||
source_url: input.source_url ?? existing?.source_url ?? null,
|
|
||||||
currency: input.currency ?? existing?.currency ?? "SAR",
|
|
||||||
availability: input.availability ?? existing?.availability ?? "unknown",
|
|
||||||
name: resolvedName,
|
|
||||||
name_en: input.name_en ?? existing?.name_en ?? null,
|
|
||||||
short_description: input.short_description ?? existing?.short_description ?? null,
|
|
||||||
description: input.description ?? existing?.description ?? null,
|
|
||||||
brand: input.brand ?? existing?.brand ?? null,
|
|
||||||
subcategory: input.subcategory ?? existing?.subcategory ?? null,
|
|
||||||
sku: input.sku ?? existing?.sku ?? null,
|
|
||||||
category_id: categoryId,
|
|
||||||
price: toMoney(resolvedPrice),
|
|
||||||
original_price:
|
|
||||||
input.original_price !== undefined
|
|
||||||
? toMoney(input.original_price)
|
|
||||||
: existing?.original_price ?? null,
|
|
||||||
images: input.images?.length ? input.images : existing?.images ?? [],
|
|
||||||
sizes: input.sizes?.length ? input.sizes : existing?.sizes ?? [],
|
|
||||||
colors: input.colors?.length ? input.colors : existing?.colors ?? [],
|
|
||||||
specs: Object.keys(input.specs ?? {}).length ? input.specs ?? {} : existing?.specs ?? {},
|
|
||||||
marketing_points:
|
|
||||||
input.marketing_points?.length ? input.marketing_points : existing?.marketing_points ?? [],
|
|
||||||
variants: input.variants?.length ? input.variants : existing?.variants ?? [],
|
|
||||||
tags: input.tags?.length ? input.tags : existing?.tags ?? [],
|
|
||||||
metadata: Object.keys(input.metadata ?? {}).length ? input.metadata ?? {} : existing?.metadata ?? {},
|
|
||||||
stock: resolvedStock,
|
|
||||||
rating: toMoney(resolvedRating),
|
|
||||||
review_count: resolvedReviewCount,
|
|
||||||
is_trending: input.is_trending ?? existing?.is_trending ?? false,
|
|
||||||
is_bestseller: input.is_bestseller ?? existing?.is_bestseller ?? false,
|
|
||||||
is_new: input.is_new ?? existing?.is_new ?? true,
|
|
||||||
is_top_rated: input.is_top_rated ?? existing?.is_top_rated ?? false,
|
|
||||||
last_synced_at: new Date(),
|
|
||||||
updated_at: new Date(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
const [updated] = await db
|
|
||||||
.update(productsTable)
|
|
||||||
.set(values)
|
|
||||||
.where(eq(productsTable.id, existing.id))
|
|
||||||
.returning();
|
|
||||||
return { mode: "updated" as const, product: updated ?? existing };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [created] = await db.insert(productsTable).values(values).returning();
|
|
||||||
return { mode: "created" as const, product: created };
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyWebhookPatch(input: WebhookProductPatch) {
|
|
||||||
const source = input.source ?? "shein";
|
|
||||||
return upsertExternalProduct({
|
|
||||||
...input,
|
|
||||||
source,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function logIntegrationEvent(params: {
|
|
||||||
source: string;
|
|
||||||
eventType: string;
|
|
||||||
status: string;
|
|
||||||
payload: Record<string, unknown>;
|
|
||||||
externalId?: string;
|
|
||||||
dedupeKey?: string;
|
|
||||||
itemsTotal?: number;
|
|
||||||
itemsSucceeded?: number;
|
|
||||||
itemsFailed?: number;
|
|
||||||
error?: string;
|
|
||||||
}) {
|
|
||||||
const [created] = await db
|
|
||||||
.insert(integrationEventsTable)
|
|
||||||
.values({
|
|
||||||
source: params.source,
|
|
||||||
event_type: params.eventType,
|
|
||||||
status: params.status,
|
|
||||||
external_id: params.externalId ?? null,
|
|
||||||
dedupe_key: params.dedupeKey ?? null,
|
|
||||||
items_total: params.itemsTotal ?? 0,
|
|
||||||
items_succeeded: params.itemsSucceeded ?? 0,
|
|
||||||
items_failed: params.itemsFailed ?? 0,
|
|
||||||
error: params.error ?? null,
|
|
||||||
payload: params.payload,
|
|
||||||
processed_at: new Date(),
|
|
||||||
})
|
|
||||||
.returning();
|
|
||||||
|
|
||||||
return created;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getPipelineStatus() {
|
|
||||||
const [productCounts, recentEvents] = await Promise.all([
|
|
||||||
db
|
|
||||||
.select({
|
|
||||||
total: sql<number>`CAST(COUNT(*) AS INTEGER)`,
|
|
||||||
shein: sql<number>`CAST(SUM(CASE WHEN ${productsTable.source} = 'shein' THEN 1 ELSE 0 END) AS INTEGER)`,
|
|
||||||
extra: sql<number>`CAST(SUM(CASE WHEN ${productsTable.source} = 'extra' THEN 1 ELSE 0 END) AS INTEGER)`,
|
|
||||||
})
|
|
||||||
.from(productsTable),
|
|
||||||
db
|
|
||||||
.select()
|
|
||||||
.from(integrationEventsTable)
|
|
||||||
.orderBy(desc(integrationEventsTable.created_at))
|
|
||||||
.limit(10),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
|
||||||
database: {
|
|
||||||
configured: Boolean(process.env["DATABASE_URL"]),
|
|
||||||
provider: process.env["DATABASE_URL"]?.includes("supabase.co") ? "supabase" : "postgresql",
|
|
||||||
pool: {
|
|
||||||
max: Number(process.env["DB_POOL_MAX"] ?? 20),
|
|
||||||
query_timeout_ms: Number(process.env["DB_QUERY_TIMEOUT_MS"] ?? 15000),
|
|
||||||
statement_timeout_ms: Number(process.env["DB_STATEMENT_TIMEOUT_MS"] ?? 15000),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
security: {
|
|
||||||
api_ingest_key_configured: Boolean(process.env["API_INGEST_KEY"]),
|
|
||||||
webhook_secret_configured: Boolean(process.env["SHEIN_WEBHOOK_SECRET"] || process.env["WEBHOOK_SECRET"]),
|
|
||||||
admin_token_configured: Boolean(process.env["ADMIN_TOKEN"]),
|
|
||||||
},
|
|
||||||
catalog: {
|
|
||||||
total_products: productCounts[0]?.total ?? 0,
|
|
||||||
shein_products: productCounts[0]?.shein ?? 0,
|
|
||||||
extra_products: productCounts[0]?.extra ?? 0,
|
|
||||||
},
|
|
||||||
recent_events: recentEvents,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,67 +0,0 @@
|
|||||||
import crypto from "node:crypto";
|
|
||||||
import type { Request, Response, NextFunction } from "express";
|
|
||||||
|
|
||||||
function readAuthToken(req: Request): string {
|
|
||||||
const apiKey = req.header("x-api-key")?.trim();
|
|
||||||
if (apiKey) return apiKey;
|
|
||||||
const auth = req.header("authorization")?.trim() ?? "";
|
|
||||||
return auth.startsWith("Bearer ") ? auth.slice(7).trim() : auth;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireApiKey(req: Request, res: Response, next: NextFunction): void {
|
|
||||||
const configuredKey = process.env["API_INGEST_KEY"]?.trim();
|
|
||||||
if (!configuredKey) {
|
|
||||||
res.status(503).json({ error: "API ingest key is not configured on this server" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const providedKey = readAuthToken(req);
|
|
||||||
if (!providedKey || providedKey !== configuredKey) {
|
|
||||||
res.status(401).json({ error: "Unauthorized — valid API key required" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
}
|
|
||||||
|
|
||||||
function extractSignature(req: Request): string {
|
|
||||||
return (
|
|
||||||
req.header("x-webhook-signature")?.trim() ||
|
|
||||||
req.header("x-signature")?.trim() ||
|
|
||||||
req.header("x-hub-signature-256")?.trim() ||
|
|
||||||
""
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function requireWebhookSignature(secretEnvNames: string[]): (req: Request, res: Response, next: NextFunction) => void {
|
|
||||||
return (req, res, next) => {
|
|
||||||
const secret = secretEnvNames
|
|
||||||
.map((name) => process.env[name]?.trim())
|
|
||||||
.find((value): value is string => Boolean(value));
|
|
||||||
|
|
||||||
if (!secret) {
|
|
||||||
res.status(503).json({ error: "Webhook signature secret is not configured on this server" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rawBody = req.rawBody ?? JSON.stringify(req.body ?? {});
|
|
||||||
const provided = extractSignature(req).replace(/^sha256=/i, "").toLowerCase();
|
|
||||||
|
|
||||||
if (!provided) {
|
|
||||||
res.status(401).json({ error: "Missing webhook signature" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const expected = crypto.createHmac("sha256", secret).update(rawBody).digest("hex");
|
|
||||||
const isValid =
|
|
||||||
provided.length === expected.length &&
|
|
||||||
crypto.timingSafeEqual(Buffer.from(provided), Buffer.from(expected));
|
|
||||||
|
|
||||||
if (!isValid) {
|
|
||||||
res.status(401).json({ error: "Invalid webhook signature" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
next();
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -15,7 +15,6 @@ import checkoutEventsRouter from "./checkout-events";
|
|||||||
import storeSettingsRouter from "./store-settings";
|
import storeSettingsRouter from "./store-settings";
|
||||||
import imageProxyRouter from "./image-proxy";
|
import imageProxyRouter from "./image-proxy";
|
||||||
import integrationsRouter from "./integrations";
|
import integrationsRouter from "./integrations";
|
||||||
import ingestRouter from "./ingest";
|
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
|
|
||||||
@ -35,6 +34,5 @@ router.use(analyticsRouter);
|
|||||||
router.use(storeSettingsRouter);
|
router.use(storeSettingsRouter);
|
||||||
router.use(imageProxyRouter);
|
router.use(imageProxyRouter);
|
||||||
router.use(integrationsRouter);
|
router.use(integrationsRouter);
|
||||||
router.use(ingestRouter);
|
|
||||||
|
|
||||||
export default router;
|
export default router;
|
||||||
|
|||||||
@ -1,193 +0,0 @@
|
|||||||
import { Router, type IRouter } from "express";
|
|
||||||
import { db } from "@workspace/db";
|
|
||||||
import { sql } from "drizzle-orm";
|
|
||||||
import { requireAdmin } from "../middleware/auth";
|
|
||||||
import { requireApiKey, requireWebhookSignature } from "../middleware/api-key";
|
|
||||||
import {
|
|
||||||
applyWebhookPatch,
|
|
||||||
getPipelineStatus,
|
|
||||||
logIntegrationEvent,
|
|
||||||
normalizeProductInput,
|
|
||||||
normalizeWebhookPatch,
|
|
||||||
upsertExternalProduct,
|
|
||||||
type SourceName,
|
|
||||||
} from "../lib/ingest";
|
|
||||||
|
|
||||||
const router: IRouter = Router();
|
|
||||||
|
|
||||||
function resolveSource(value: unknown, fallback: SourceName): SourceName {
|
|
||||||
return value === "shein" ? "shein" : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.get("/integrations/pipeline/status", requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
await db.execute(sql`select 1`);
|
|
||||||
const status = await getPipelineStatus();
|
|
||||||
res.json({ ok: true, ...status });
|
|
||||||
} catch (err) {
|
|
||||||
req.log.error({ err }, "Failed to fetch pipeline status");
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : "Internal server error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/ingest/events", requireAdmin, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const status = await getPipelineStatus();
|
|
||||||
res.json(status.recent_events);
|
|
||||||
} catch (err) {
|
|
||||||
req.log.error({ err }, "Failed to list ingest events");
|
|
||||||
res.status(500).json({ error: err instanceof Error ? err.message : "Internal server error" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/ingest/products/upsert", requireApiKey, async (req, res) => {
|
|
||||||
try {
|
|
||||||
const payload = normalizeProductInput(req.body, resolveSource(req.body?.source, "extra"));
|
|
||||||
const result = await upsertExternalProduct(payload);
|
|
||||||
await logIntegrationEvent({
|
|
||||||
source: payload.source,
|
|
||||||
eventType: "products.upsert",
|
|
||||||
status: "processed",
|
|
||||||
payload: req.body ?? {},
|
|
||||||
externalId: payload.external_id ?? payload.sku,
|
|
||||||
itemsTotal: 1,
|
|
||||||
itemsSucceeded: 1,
|
|
||||||
itemsFailed: 0,
|
|
||||||
});
|
|
||||||
res.status(result.mode === "created" ? 201 : 200).json(result);
|
|
||||||
} catch (err) {
|
|
||||||
req.log.error({ err }, "Failed to upsert ingested product");
|
|
||||||
await logIntegrationEvent({
|
|
||||||
source: resolveSource(req.body?.source, "extra"),
|
|
||||||
eventType: "products.upsert",
|
|
||||||
status: "failed",
|
|
||||||
payload: (req.body ?? {}) as Record<string, unknown>,
|
|
||||||
externalId: req.body?.external_id ?? req.body?.sku,
|
|
||||||
itemsTotal: 1,
|
|
||||||
itemsSucceeded: 0,
|
|
||||||
itemsFailed: 1,
|
|
||||||
error: err instanceof Error ? err.message : "Unknown error",
|
|
||||||
});
|
|
||||||
res.status(400).json({ error: err instanceof Error ? err.message : "Invalid payload" });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post("/ingest/products/bulk", requireApiKey, async (req, res) => {
|
|
||||||
const source = resolveSource(req.body?.source, "extra");
|
|
||||||
const products = Array.isArray(req.body?.products) ? req.body.products : [];
|
|
||||||
const webhookId = typeof req.body?.webhook_id === "string" ? req.body.webhook_id : undefined;
|
|
||||||
|
|
||||||
if (products.length === 0) {
|
|
||||||
res.status(400).json({ error: "Request body must include a non-empty products array" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let processed = 0;
|
|
||||||
let created = 0;
|
|
||||||
let updated = 0;
|
|
||||||
const errors: Array<{ index: number; message: string }> = [];
|
|
||||||
|
|
||||||
for (const [index, rawProduct] of products.entries()) {
|
|
||||||
try {
|
|
||||||
const product = normalizeProductInput(rawProduct, source);
|
|
||||||
const result = await upsertExternalProduct(product);
|
|
||||||
processed += 1;
|
|
||||||
if (result.mode === "created") created += 1;
|
|
||||||
if (result.mode === "updated") updated += 1;
|
|
||||||
} catch (err) {
|
|
||||||
errors.push({
|
|
||||||
index,
|
|
||||||
message: err instanceof Error ? err.message : "Invalid payload",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = errors.length > 0 ? (processed > 0 ? "partial" : "failed") : "processed";
|
|
||||||
|
|
||||||
await logIntegrationEvent({
|
|
||||||
source,
|
|
||||||
eventType: "products.bulk_sync",
|
|
||||||
status,
|
|
||||||
payload: {
|
|
||||||
source,
|
|
||||||
webhook_id: webhookId,
|
|
||||||
total_received: products.length,
|
|
||||||
sample: products.slice(0, 3),
|
|
||||||
},
|
|
||||||
dedupeKey: webhookId,
|
|
||||||
itemsTotal: products.length,
|
|
||||||
itemsSucceeded: processed,
|
|
||||||
itemsFailed: errors.length,
|
|
||||||
error: errors.length > 0 ? JSON.stringify(errors.slice(0, 10)) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(errors.length > 0 ? 207 : 200).json({
|
|
||||||
source,
|
|
||||||
total_received: products.length,
|
|
||||||
processed,
|
|
||||||
created,
|
|
||||||
updated,
|
|
||||||
failed: errors.length,
|
|
||||||
errors,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/webhooks/shein/products",
|
|
||||||
requireApiKey,
|
|
||||||
requireWebhookSignature(["SHEIN_WEBHOOK_SECRET", "WEBHOOK_SECRET"]),
|
|
||||||
async (req, res) => {
|
|
||||||
const eventType = typeof req.body?.event === "string" ? req.body.event : "shein.products.changed";
|
|
||||||
const webhookId = typeof req.body?.webhook_id === "string" ? req.body.webhook_id : undefined;
|
|
||||||
const items = Array.isArray(req.body?.products) ? req.body.products : [];
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
res.status(400).json({ error: "Webhook body must include a non-empty products array" });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let processed = 0;
|
|
||||||
const errors: Array<{ index: number; message: string }> = [];
|
|
||||||
|
|
||||||
for (const [index, rawItem] of items.entries()) {
|
|
||||||
try {
|
|
||||||
const patch = normalizeWebhookPatch(rawItem, "shein");
|
|
||||||
await applyWebhookPatch({ ...patch, source: "shein" });
|
|
||||||
processed += 1;
|
|
||||||
} catch (err) {
|
|
||||||
errors.push({
|
|
||||||
index,
|
|
||||||
message: err instanceof Error ? err.message : "Invalid webhook item",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = errors.length > 0 ? (processed > 0 ? "partial" : "failed") : "processed";
|
|
||||||
|
|
||||||
await logIntegrationEvent({
|
|
||||||
source: "shein",
|
|
||||||
eventType,
|
|
||||||
status,
|
|
||||||
payload: {
|
|
||||||
webhook_id: webhookId,
|
|
||||||
event: eventType,
|
|
||||||
total_received: items.length,
|
|
||||||
sample: items.slice(0, 3),
|
|
||||||
},
|
|
||||||
dedupeKey: webhookId,
|
|
||||||
itemsTotal: items.length,
|
|
||||||
itemsSucceeded: processed,
|
|
||||||
itemsFailed: errors.length,
|
|
||||||
error: errors.length > 0 ? JSON.stringify(errors.slice(0, 10)) : undefined,
|
|
||||||
});
|
|
||||||
|
|
||||||
res.status(errors.length > 0 ? 207 : 200).json({
|
|
||||||
event: eventType,
|
|
||||||
processed,
|
|
||||||
failed: errors.length,
|
|
||||||
errors,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
@ -147,7 +147,7 @@ router.get("/integrations/shein-categories", async (req, res) => {
|
|||||||
try {
|
try {
|
||||||
let categories: SheinCategory[] = [];
|
let categories: SheinCategory[] = [];
|
||||||
let source = "preset";
|
let source = "preset";
|
||||||
let scrapeResult: Awaited<ReturnType<typeof fetchSheinCategories>> | null = null;
|
let scrapeResult: { success: boolean; error?: string; runId?: string } | null = null;
|
||||||
|
|
||||||
if (mode === "scrape") {
|
if (mode === "scrape") {
|
||||||
scrapeResult = await fetchSheinCategories();
|
scrapeResult = await fetchSheinCategories();
|
||||||
|
|||||||
@ -5,10 +5,6 @@ import { requireAdmin } from "../middleware/auth";
|
|||||||
|
|
||||||
const router: IRouter = Router();
|
const router: IRouter = Router();
|
||||||
|
|
||||||
function getSingleParamValue(value: string | string[] | undefined): string {
|
|
||||||
return Array.isArray(value) ? value[0] ?? "" : value ?? "";
|
|
||||||
}
|
|
||||||
|
|
||||||
function generateOrderNumber(): string {
|
function generateOrderNumber(): string {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const random = Math.floor(Math.random() * 1000).toString().padStart(3, "0");
|
const random = Math.floor(Math.random() * 1000).toString().padStart(3, "0");
|
||||||
@ -45,7 +41,7 @@ router.get("/orders", async (req, res) => {
|
|||||||
|
|
||||||
router.get("/orders/:id", async (req, res) => {
|
router.get("/orders/:id", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(getSingleParamValue(req.params.id), 10);
|
const id = parseInt(req.params.id);
|
||||||
const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id));
|
const [order] = await db.select().from(ordersTable).where(eq(ordersTable.id, id));
|
||||||
if (!order) return res.status(404).json({ error: "Order not found" });
|
if (!order) return res.status(404).json({ error: "Order not found" });
|
||||||
res.json(order);
|
res.json(order);
|
||||||
@ -195,7 +191,7 @@ router.post("/orders", async (req, res) => {
|
|||||||
|
|
||||||
router.delete("/orders/:id", requireAdmin, async (req, res) => {
|
router.delete("/orders/:id", requireAdmin, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(getSingleParamValue(req.params.id), 10);
|
const id = parseInt(req.params.id);
|
||||||
await db.delete(ordersTable).where(eq(ordersTable.id, id));
|
await db.delete(ordersTable).where(eq(ordersTable.id, id));
|
||||||
res.json({ success: true });
|
res.json({ success: true });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@ -206,7 +202,7 @@ router.delete("/orders/:id", requireAdmin, async (req, res) => {
|
|||||||
|
|
||||||
router.put("/orders/:id/status", async (req, res) => {
|
router.put("/orders/:id/status", async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const id = parseInt(getSingleParamValue(req.params.id), 10);
|
const id = parseInt(req.params.id);
|
||||||
const { status, tracking_number } = req.body;
|
const { status, tracking_number } = req.body;
|
||||||
|
|
||||||
// Fetch current order first
|
// Fetch current order first
|
||||||
|
|||||||
9
artifacts/api-server/src/types/express.d.ts
vendored
9
artifacts/api-server/src/types/express.d.ts
vendored
@ -1,9 +0,0 @@
|
|||||||
declare global {
|
|
||||||
namespace Express {
|
|
||||||
interface Request {
|
|
||||||
rawBody?: string;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@ -3,14 +3,9 @@
|
|||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "dist",
|
"outDir": "dist",
|
||||||
"rootDir": "src",
|
"rootDir": "src",
|
||||||
"types": [
|
"types": ["node"]
|
||||||
"node"
|
|
||||||
],
|
|
||||||
"noImplicitReturns": false
|
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["src"],
|
||||||
"src"
|
|
||||||
],
|
|
||||||
"references": [
|
"references": [
|
||||||
{
|
{
|
||||||
"path": "../../lib/db"
|
"path": "../../lib/db"
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -1,4 +1,2 @@
|
|||||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||||
const OVERRIDE = import.meta.env.VITE_API_BASE_URL?.replace(/\/$/, "");
|
export const API = `${BASE}/api`;
|
||||||
|
|
||||||
export const API = OVERRIDE || `${BASE}/api`;
|
|
||||||
|
|||||||
@ -3,8 +3,8 @@ export type Lang = "ar" | "en";
|
|||||||
export const translations = {
|
export const translations = {
|
||||||
ar: {
|
ar: {
|
||||||
// Store
|
// Store
|
||||||
store_name: "رين",
|
store_name: "اكسترا",
|
||||||
store_tagline: "متجر رين لتجربة تسوق سعودية أنيقة تجمع الإلكترونيات، الجمال، المنزل والعروض اليومية.",
|
store_tagline: "الوجهة الأولى للإلكترونيات والأجهزة المنزلية في المملكة العربية السعودية.",
|
||||||
// Header
|
// Header
|
||||||
search_placeholder: "ابحث عن منتجات...",
|
search_placeholder: "ابحث عن منتجات...",
|
||||||
top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة",
|
top_bar_offer: "⚡ عروض خاصة — خصم يصل إلى 40% على المنتجات المختارة",
|
||||||
@ -15,7 +15,7 @@ export const translations = {
|
|||||||
user_cart: "سلتي",
|
user_cart: "سلتي",
|
||||||
user_logout: "تسجيل الخروج",
|
user_logout: "تسجيل الخروج",
|
||||||
user_guest: "مستخدم",
|
user_guest: "مستخدم",
|
||||||
user_member: "عضو رين",
|
user_member: "عضو اكسترا",
|
||||||
// Auth
|
// Auth
|
||||||
auth_login_tab: "تسجيل الدخول",
|
auth_login_tab: "تسجيل الدخول",
|
||||||
auth_register_tab: "إنشاء حساب",
|
auth_register_tab: "إنشاء حساب",
|
||||||
@ -46,7 +46,7 @@ export const translations = {
|
|||||||
server_error: "تعذر الاتصال بالخادم",
|
server_error: "تعذر الاتصال بالخادم",
|
||||||
// Home sections
|
// Home sections
|
||||||
section_view_all: "عرض الكل ←",
|
section_view_all: "عرض الكل ←",
|
||||||
section_extra_title: "رين — إلكترونيات مختارة",
|
section_extra_title: "اكسترا — إلكترونيات وأجهزة",
|
||||||
section_shein_sub: "Fashion, Beauty & Home",
|
section_shein_sub: "Fashion, Beauty & Home",
|
||||||
// Product card
|
// Product card
|
||||||
product_new: "جديد",
|
product_new: "جديد",
|
||||||
@ -168,10 +168,10 @@ export const translations = {
|
|||||||
verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع",
|
verifying_sub: "يرجى الانتظار، يتم التحقق من عملية الدفع",
|
||||||
payment_success: "✅ تم الدفع بنجاح!",
|
payment_success: "✅ تم الدفع بنجاح!",
|
||||||
payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...",
|
payment_success_sub: "شكراً لك! جاري تحويلك للصفحة الرئيسية...",
|
||||||
ssl_badge: "مدفوعاتك آمنة 100% بتشفير TLS وبنية دفع محمية على مدار الساعة 🔒",
|
ssl_badge: "مدفوعاتك آمنة بتشفير TLS ومعايير PCI DSS المعتمدة من مؤسسة النقد العربي السعودي 🔒",
|
||||||
delivery_days_3: "توصيل سريع داخل المملكة",
|
delivery_days_3: "توصيل 3 أيام عمل",
|
||||||
delivery_days_5: "توصيل قياسي داخل المملكة",
|
delivery_days_5: "توصيل 5 أيام عمل",
|
||||||
delivery_days_7: "توصيل إلى جميع المناطق",
|
delivery_days_7: "توصيل 7 أيام عمل",
|
||||||
// Profile
|
// Profile
|
||||||
profile_login_first: "سجّل دخولك أولاً",
|
profile_login_first: "سجّل دخولك أولاً",
|
||||||
profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك",
|
profile_login_sub: "للوصول إلى ملفك الشخصي وطلباتك",
|
||||||
@ -195,7 +195,7 @@ export const translations = {
|
|||||||
footer_warranty: "الضمان",
|
footer_warranty: "الضمان",
|
||||||
footer_contact: "تواصل معنا",
|
footer_contact: "تواصل معنا",
|
||||||
footer_address: "الرياض، المملكة العربية السعودية",
|
footer_address: "الرياض، المملكة العربية السعودية",
|
||||||
footer_copyright: "© 2025 متجر رين. جميع الحقوق محفوظة.",
|
footer_copyright: "© 2025 اكسترا السعودية. جميع الحقوق محفوظة.",
|
||||||
// 404
|
// 404
|
||||||
page_not_found: "الصفحة غير موجودة",
|
page_not_found: "الصفحة غير موجودة",
|
||||||
not_found: "الصفحة غير موجودة",
|
not_found: "الصفحة غير موجودة",
|
||||||
@ -209,7 +209,6 @@ export const translations = {
|
|||||||
section_trending_title: "الأكثر رواجاً",
|
section_trending_title: "الأكثر رواجاً",
|
||||||
section_bestseller_title: "الأكثر مبيعاً",
|
section_bestseller_title: "الأكثر مبيعاً",
|
||||||
section_new_title: "وصل حديثاً",
|
section_new_title: "وصل حديثاً",
|
||||||
section_top_rated_title: "أعلى تقييماً",
|
|
||||||
shein_section_title: "أزياء، جمال ومنزل",
|
shein_section_title: "أزياء، جمال ومنزل",
|
||||||
browse_all_cat: "تصفح جميع المنتجات",
|
browse_all_cat: "تصفح جميع المنتجات",
|
||||||
// Mega menu
|
// Mega menu
|
||||||
@ -242,7 +241,7 @@ export const translations = {
|
|||||||
login: "تسجيل الدخول",
|
login: "تسجيل الدخول",
|
||||||
logout: "تسجيل الخروج",
|
logout: "تسجيل الخروج",
|
||||||
user_default: "مستخدم",
|
user_default: "مستخدم",
|
||||||
extra_member: "عضو رين",
|
extra_member: "عضو اكسترا",
|
||||||
my_orders: "طلباتي",
|
my_orders: "طلباتي",
|
||||||
my_orders_sub: "تتبع وإدارة طلباتك",
|
my_orders_sub: "تتبع وإدارة طلباتك",
|
||||||
wishlist: "قائمة الأمنيات",
|
wishlist: "قائمة الأمنيات",
|
||||||
@ -255,8 +254,8 @@ export const translations = {
|
|||||||
|
|
||||||
en: {
|
en: {
|
||||||
// Store
|
// Store
|
||||||
store_name: "Rain",
|
store_name: "eXtra",
|
||||||
store_tagline: "Rain Store for a premium Saudi shopping experience across electronics, beauty, home, and daily deals.",
|
store_tagline: "Saudi Arabia's #1 destination for electronics and home appliances.",
|
||||||
// Header
|
// Header
|
||||||
search_placeholder: "Search products...",
|
search_placeholder: "Search products...",
|
||||||
top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items",
|
top_bar_offer: "⚡ Special Offers — Up to 40% off on selected items",
|
||||||
@ -267,7 +266,7 @@ export const translations = {
|
|||||||
user_cart: "My Cart",
|
user_cart: "My Cart",
|
||||||
user_logout: "Sign Out",
|
user_logout: "Sign Out",
|
||||||
user_guest: "User",
|
user_guest: "User",
|
||||||
user_member: "Rain Member",
|
user_member: "eXtra Member",
|
||||||
// Auth
|
// Auth
|
||||||
auth_login_tab: "Sign In",
|
auth_login_tab: "Sign In",
|
||||||
auth_register_tab: "Create Account",
|
auth_register_tab: "Create Account",
|
||||||
@ -298,7 +297,7 @@ export const translations = {
|
|||||||
server_error: "Could not connect to server",
|
server_error: "Could not connect to server",
|
||||||
// Home sections
|
// Home sections
|
||||||
section_view_all: "View All →",
|
section_view_all: "View All →",
|
||||||
section_extra_title: "Rain — Featured Electronics",
|
section_extra_title: "eXtra — Electronics & Appliances",
|
||||||
section_shein_sub: "Fashion, Beauty & Home",
|
section_shein_sub: "Fashion, Beauty & Home",
|
||||||
// Product card
|
// Product card
|
||||||
product_new: "NEW",
|
product_new: "NEW",
|
||||||
@ -420,7 +419,7 @@ export const translations = {
|
|||||||
verifying_sub: "Please wait while we verify your payment",
|
verifying_sub: "Please wait while we verify your payment",
|
||||||
payment_success: "✅ Payment Successful!",
|
payment_success: "✅ Payment Successful!",
|
||||||
payment_success_sub: "Thank you! Redirecting to home page...",
|
payment_success_sub: "Thank you! Redirecting to home page...",
|
||||||
ssl_badge: "Your payments are protected with TLS encryption and a continuously monitored secure checkout 🔒",
|
ssl_badge: "Your payments are secured with TLS encryption & PCI DSS standards approved by Saudi Central Bank (SAMA) 🔒",
|
||||||
delivery_days_3: "3 business days delivery",
|
delivery_days_3: "3 business days delivery",
|
||||||
delivery_days_5: "5 business days delivery",
|
delivery_days_5: "5 business days delivery",
|
||||||
delivery_days_7: "7 business days delivery",
|
delivery_days_7: "7 business days delivery",
|
||||||
@ -447,7 +446,7 @@ export const translations = {
|
|||||||
footer_warranty: "Warranty",
|
footer_warranty: "Warranty",
|
||||||
footer_contact: "Contact Us",
|
footer_contact: "Contact Us",
|
||||||
footer_address: "Riyadh, Kingdom of Saudi Arabia",
|
footer_address: "Riyadh, Kingdom of Saudi Arabia",
|
||||||
footer_copyright: "© 2025 Rain Store. All rights reserved.",
|
footer_copyright: "© 2025 eXtra Saudi Arabia. All rights reserved.",
|
||||||
// 404
|
// 404
|
||||||
page_not_found: "Page Not Found",
|
page_not_found: "Page Not Found",
|
||||||
not_found: "Page Not Found",
|
not_found: "Page Not Found",
|
||||||
@ -461,7 +460,6 @@ export const translations = {
|
|||||||
section_trending_title: "Trending",
|
section_trending_title: "Trending",
|
||||||
section_bestseller_title: "Best Sellers",
|
section_bestseller_title: "Best Sellers",
|
||||||
section_new_title: "New Arrivals",
|
section_new_title: "New Arrivals",
|
||||||
section_top_rated_title: "Top Rated",
|
|
||||||
shein_section_title: "Fashion, Beauty & Home",
|
shein_section_title: "Fashion, Beauty & Home",
|
||||||
browse_all_cat: "Browse All Products",
|
browse_all_cat: "Browse All Products",
|
||||||
// Mega menu
|
// Mega menu
|
||||||
@ -494,7 +492,7 @@ export const translations = {
|
|||||||
login: "Sign In",
|
login: "Sign In",
|
||||||
logout: "Sign Out",
|
logout: "Sign Out",
|
||||||
user_default: "User",
|
user_default: "User",
|
||||||
extra_member: "Rain Member",
|
extra_member: "eXtra Member",
|
||||||
my_orders: "My Orders",
|
my_orders: "My Orders",
|
||||||
my_orders_sub: "Track and manage your orders",
|
my_orders_sub: "Track and manage your orders",
|
||||||
wishlist: "Wishlist",
|
wishlist: "Wishlist",
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
export type PreviewAuthUser = {
|
|
||||||
id: number;
|
|
||||||
name: string | null;
|
|
||||||
email: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type StoredPreviewUser = PreviewAuthUser & {
|
|
||||||
password: string;
|
|
||||||
created_at: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
const STORE_USERS_KEY = "extra_preview_users";
|
|
||||||
const STORE_AUTH_SALT = "extra_preview_auth_v1";
|
|
||||||
export const PREVIEW_ADMIN_TOKEN = "preview_admin_token";
|
|
||||||
|
|
||||||
const DEMO_PREVIEW_USER: StoredPreviewUser = {
|
|
||||||
id: 1,
|
|
||||||
name: "عميل تجريبي",
|
|
||||||
email: "demo@extra.sa",
|
|
||||||
password: "Extra123",
|
|
||||||
created_at: "2026-03-28T00:00:00.000Z",
|
|
||||||
};
|
|
||||||
|
|
||||||
function readUsers(): StoredPreviewUser[] {
|
|
||||||
if (typeof localStorage === "undefined") return [DEMO_PREVIEW_USER];
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(localStorage.getItem(STORE_USERS_KEY) || "[]");
|
|
||||||
const users = Array.isArray(parsed) ? parsed : [];
|
|
||||||
return users.some((user) => user.email === DEMO_PREVIEW_USER.email) ? users : [DEMO_PREVIEW_USER, ...users];
|
|
||||||
} catch {
|
|
||||||
return [DEMO_PREVIEW_USER];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeUsers(users: StoredPreviewUser[]) {
|
|
||||||
if (typeof localStorage === "undefined") return;
|
|
||||||
localStorage.setItem(STORE_USERS_KEY, JSON.stringify(users));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeEmail(email: string) {
|
|
||||||
return email.trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeToken(userId: number) {
|
|
||||||
return `preview_user_${userId}_${STORE_AUTH_SALT}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function isJsonResponse(res: Response) {
|
|
||||||
const contentType = res.headers.get("content-type") || "";
|
|
||||||
return contentType.includes("application/json");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerPreviewStoreUser(input: {
|
|
||||||
name?: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
confirm_password?: string;
|
|
||||||
}) {
|
|
||||||
const email = normalizeEmail(input.email);
|
|
||||||
const name = input.name?.trim() || null;
|
|
||||||
const password = input.password || "";
|
|
||||||
const confirm = input.confirm_password || "";
|
|
||||||
|
|
||||||
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) {
|
|
||||||
throw new Error("البريد الإلكتروني غير صحيح");
|
|
||||||
}
|
|
||||||
if (password.length < 8) {
|
|
||||||
throw new Error("كلمة المرور يجب أن تكون 8 أحرف على الأقل");
|
|
||||||
}
|
|
||||||
if (!/[A-Z]/.test(password)) {
|
|
||||||
throw new Error("كلمة المرور يجب أن تحتوي على حرف كبير");
|
|
||||||
}
|
|
||||||
if (!/[0-9]/.test(password)) {
|
|
||||||
throw new Error("كلمة المرور يجب أن تحتوي على رقم");
|
|
||||||
}
|
|
||||||
if (password !== confirm) {
|
|
||||||
throw new Error("كلمة المرور وتأكيدها غير متطابقين");
|
|
||||||
}
|
|
||||||
|
|
||||||
const users = readUsers();
|
|
||||||
if (users.some((user) => user.email === email)) {
|
|
||||||
throw new Error("البريد الإلكتروني مستخدم بالفعل");
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = users.reduce((max, user) => Math.max(max, user.id), 0) + 1;
|
|
||||||
const newUser: StoredPreviewUser = {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
email,
|
|
||||||
password,
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
users.push(newUser);
|
|
||||||
writeUsers(users);
|
|
||||||
|
|
||||||
const user: PreviewAuthUser = { id: newUser.id, name: newUser.name, email: newUser.email };
|
|
||||||
return { user, token: makeToken(user.id) };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loginPreviewStoreUser(input: { email: string; password: string }) {
|
|
||||||
const email = normalizeEmail(input.email);
|
|
||||||
const users = readUsers();
|
|
||||||
const user = users.find((entry) => entry.email === email && entry.password === input.password);
|
|
||||||
if (!user) {
|
|
||||||
throw new Error("البريد الإلكتروني أو كلمة المرور غير صحيحة");
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
user: { id: user.id, name: user.name, email: user.email },
|
|
||||||
token: makeToken(user.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function loginPreviewAdmin(input: { username: string; password: string }) {
|
|
||||||
if (input.username === "admin" && input.password === "admin123") {
|
|
||||||
return { token: PREVIEW_ADMIN_TOKEN, username: "admin" };
|
|
||||||
}
|
|
||||||
throw new Error("بيانات الدخول غير صحيحة");
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -4,7 +4,13 @@ import tailwindcss from "@tailwindcss/vite";
|
|||||||
import path from "path";
|
import path from "path";
|
||||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
||||||
|
|
||||||
const rawPort = process.env.PORT ?? "3001";
|
const rawPort = process.env.PORT;
|
||||||
|
|
||||||
|
if (!rawPort) {
|
||||||
|
throw new Error(
|
||||||
|
"PORT environment variable is required but was not provided.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(rawPort);
|
const port = Number(rawPort);
|
||||||
|
|
||||||
@ -12,8 +18,13 @@ if (Number.isNaN(port) || port <= 0) {
|
|||||||
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = process.env.BASE_PATH ?? "/";
|
const basePath = process.env.BASE_PATH;
|
||||||
const apiProxyTarget = process.env.API_SERVER_URL ?? "http://127.0.0.1:8080";
|
|
||||||
|
if (!basePath) {
|
||||||
|
throw new Error(
|
||||||
|
"BASE_PATH environment variable is required but was not provided.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: basePath,
|
base: basePath,
|
||||||
@ -51,12 +62,6 @@ export default defineConfig({
|
|||||||
port,
|
port,
|
||||||
host: "0.0.0.0",
|
host: "0.0.0.0",
|
||||||
allowedHosts: true,
|
allowedHosts: true,
|
||||||
proxy: {
|
|
||||||
"/api": {
|
|
||||||
target: apiProxyTarget,
|
|
||||||
changeOrigin: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
fs: {
|
fs: {
|
||||||
strict: true,
|
strict: true,
|
||||||
deny: ["**/.*"],
|
deny: ["**/.*"],
|
||||||
|
|||||||
@ -5,7 +5,13 @@ import path from "path";
|
|||||||
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
import runtimeErrorOverlay from "@replit/vite-plugin-runtime-error-modal";
|
||||||
import { mockupPreviewPlugin } from "./mockupPreviewPlugin";
|
import { mockupPreviewPlugin } from "./mockupPreviewPlugin";
|
||||||
|
|
||||||
const rawPort = process.env.PORT ?? "3001";
|
const rawPort = process.env.PORT;
|
||||||
|
|
||||||
|
if (!rawPort) {
|
||||||
|
throw new Error(
|
||||||
|
"PORT environment variable is required but was not provided.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const port = Number(rawPort);
|
const port = Number(rawPort);
|
||||||
|
|
||||||
@ -13,7 +19,13 @@ if (Number.isNaN(port) || port <= 0) {
|
|||||||
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
throw new Error(`Invalid PORT value: "${rawPort}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const basePath = process.env.BASE_PATH ?? "/";
|
const basePath = process.env.BASE_PATH;
|
||||||
|
|
||||||
|
if (!basePath) {
|
||||||
|
throw new Error(
|
||||||
|
"BASE_PATH environment variable is required but was not provided.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: basePath,
|
base: basePath,
|
||||||
|
|||||||
@ -1,184 +0,0 @@
|
|||||||
# Flatlogic CI/CD + External DB + Ingestion API
|
|
||||||
|
|
||||||
هذا الإعداد يجعل المشروع يعمل بهذه الصورة:
|
|
||||||
|
|
||||||
- **GitHub** هو مصدر الحقيقة للكود.
|
|
||||||
- **Bolt / Replit** يدفعان التعديلات إلى نفس المستودع أو يرسلان `repository_dispatch`.
|
|
||||||
- **GitHub Actions** تبني المشروع وتتحقق منه ثم تنشره إلى VM الخاص بـ Flatlogic.
|
|
||||||
- **Flatlogic / API Server** يدير بيانات المنتجات القادمة من أدوات السحب وويبهوكات شي إن.
|
|
||||||
- **Supabase / PostgreSQL** هو مخزن البيانات الخارجي للمنتجات والفئات وسجل أحداث التكامل.
|
|
||||||
|
|
||||||
## ما تمت إضافته
|
|
||||||
|
|
||||||
- `.github/workflows/ci.yml`
|
|
||||||
- Typecheck + Build لكل Push / Pull Request.
|
|
||||||
- `.github/workflows/deploy-flatlogic.yml`
|
|
||||||
- ينشر تلقائيًا إلى VM عند التحديث على `main` أو `master`.
|
|
||||||
- يدعم `repository_dispatch` لأنواع:
|
|
||||||
- `bolt_sync`
|
|
||||||
- `replit_sync`
|
|
||||||
- `flatlogic_deploy`
|
|
||||||
- `scripts/flatlogic-deploy.sh`
|
|
||||||
- يسحب آخر نسخة من GitHub.
|
|
||||||
- يثبت الحزم.
|
|
||||||
- يشغل `typecheck` و `build`.
|
|
||||||
- يطبق schema قاعدة البيانات عبر Drizzle.
|
|
||||||
- يعيد تشغيل `extra-store` و `flatlogic-api` عبر PM2.
|
|
||||||
- `.env.example`
|
|
||||||
- كل متغيرات البيئة المطلوبة للـ DB والـ API والأمان.
|
|
||||||
- API endpoints جديدة داخل `artifacts/api-server`.
|
|
||||||
|
|
||||||
## الأسرار المطلوبة في GitHub Actions
|
|
||||||
|
|
||||||
أضف هذه القيم في **GitHub → Settings → Secrets and variables → Actions**:
|
|
||||||
|
|
||||||
### أسرار النشر إلى Flatlogic VM
|
|
||||||
|
|
||||||
- `FLATLOGIC_HOST`
|
|
||||||
- `FLATLOGIC_USER`
|
|
||||||
- `FLATLOGIC_SSH_KEY`
|
|
||||||
- `FLATLOGIC_PROJECT_DIR`
|
|
||||||
- `FLATLOGIC_DEPLOY_BRANCH` (اختياري)
|
|
||||||
|
|
||||||
### أسرار الـ backend
|
|
||||||
|
|
||||||
- `DATABASE_URL`
|
|
||||||
- `DB_SSL` = `require`
|
|
||||||
- `DB_POOL_MAX` = `20`
|
|
||||||
- `DB_QUERY_TIMEOUT_MS` = `15000`
|
|
||||||
- `DB_STATEMENT_TIMEOUT_MS` = `15000`
|
|
||||||
- `ADMIN_TOKEN`
|
|
||||||
- `API_INGEST_KEY`
|
|
||||||
- `WEBHOOK_SECRET`
|
|
||||||
- `SHEIN_WEBHOOK_SECRET`
|
|
||||||
- `API_PORT` = `8080`
|
|
||||||
- `STORE_PORT` = `3001`
|
|
||||||
|
|
||||||
## إعداد قاعدة البيانات الخارجية (Supabase / PostgreSQL)
|
|
||||||
|
|
||||||
الحد الأدنى المقترح لاستيعاب 2000 منتج من Extra + Shein:
|
|
||||||
|
|
||||||
- استخدم **Postgres خارجي** أو **Supabase**.
|
|
||||||
- يفضل في Supabase استخدام **transaction pooler** داخل `DATABASE_URL`.
|
|
||||||
- الإعدادات الافتراضية المضافة في الكود:
|
|
||||||
- `DB_POOL_MAX=20`
|
|
||||||
- `DB_QUERY_TIMEOUT_MS=15000`
|
|
||||||
- `DB_STATEMENT_TIMEOUT_MS=15000`
|
|
||||||
- `keepAlive=true`
|
|
||||||
- أضف الـ schema بالأمر:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm --filter @workspace/db run push
|
|
||||||
```
|
|
||||||
|
|
||||||
> ملاحظة: الجدول `products` صار يدعم الآن `source`, `external_id`, `source_url`, `currency`, `availability`, `metadata`, `last_synced_at` مع فهارس مخصصة للبحث والتزامن.
|
|
||||||
|
|
||||||
## API Endpoints الجديدة
|
|
||||||
|
|
||||||
### 1) Bulk ingestion للمنتجات
|
|
||||||
|
|
||||||
`POST /api/ingest/products/bulk`
|
|
||||||
|
|
||||||
Headers:
|
|
||||||
|
|
||||||
```text
|
|
||||||
x-api-key: <API_INGEST_KEY>
|
|
||||||
content-type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
Body مثال:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"source": "shein",
|
|
||||||
"webhook_id": "apify-run-123",
|
|
||||||
"products": [
|
|
||||||
{
|
|
||||||
"external_id": "shein-10001",
|
|
||||||
"sku": "SKU-10001",
|
|
||||||
"name": "فستان صيفي",
|
|
||||||
"brand": "SHEIN",
|
|
||||||
"price": 149,
|
|
||||||
"original_price": 199,
|
|
||||||
"stock": 25,
|
|
||||||
"availability": "in_stock",
|
|
||||||
"sizes": ["S", "M", "L"],
|
|
||||||
"colors": ["Black", "Pink"],
|
|
||||||
"images": ["https://example.com/1.jpg"],
|
|
||||||
"category": {
|
|
||||||
"slug": "dresses",
|
|
||||||
"name": "فساتين"
|
|
||||||
},
|
|
||||||
"source_url": "https://example.com/product/10001"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2) Upsert منتج مفرد
|
|
||||||
|
|
||||||
`POST /api/ingest/products/upsert`
|
|
||||||
|
|
||||||
نفس الحماية عبر `x-api-key`.
|
|
||||||
|
|
||||||
### 3) Webhook تحديثات شي إن
|
|
||||||
|
|
||||||
`POST /api/webhooks/shein/products`
|
|
||||||
|
|
||||||
Headers:
|
|
||||||
|
|
||||||
```text
|
|
||||||
x-api-key: <API_INGEST_KEY>
|
|
||||||
x-webhook-signature: sha256=<hmac_sha256_of_raw_body>
|
|
||||||
content-type: application/json
|
|
||||||
```
|
|
||||||
|
|
||||||
Body مثال:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"webhook_id": "shein-webhook-987",
|
|
||||||
"event": "price.updated",
|
|
||||||
"products": [
|
|
||||||
{
|
|
||||||
"external_id": "shein-10001",
|
|
||||||
"price": 139,
|
|
||||||
"stock": 12,
|
|
||||||
"availability": "low_stock",
|
|
||||||
"sizes": ["S", "M"]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### 4) Pipeline status
|
|
||||||
|
|
||||||
`GET /api/integrations/pipeline/status`
|
|
||||||
|
|
||||||
- محمي بـ `Authorization: Bearer <ADMIN_TOKEN>`
|
|
||||||
- يعرض:
|
|
||||||
- حالة إعداد الأمان
|
|
||||||
- حالة الـ DB
|
|
||||||
- عدد المنتجات حسب المصدر
|
|
||||||
- آخر أحداث التكامل
|
|
||||||
|
|
||||||
## ربط Bolt / Replit مع GitHub
|
|
||||||
|
|
||||||
أفضل سيناريو:
|
|
||||||
|
|
||||||
1. اجعل **Bolt** أو **Replit** يدفعان إلى نفس مستودع GitHub.
|
|
||||||
2. كل Push إلى `main` يشغل:
|
|
||||||
- `ci.yml`
|
|
||||||
- ثم `deploy-flatlogic.yml`
|
|
||||||
3. النتيجة: يتم تحديث الموقع والـ backend تلقائيًا.
|
|
||||||
|
|
||||||
إذا كانت الأداة لا تدفع مباشرة إلى GitHub، استخدم `repository_dispatch` من GitHub API بنوع:
|
|
||||||
|
|
||||||
- `bolt_sync`
|
|
||||||
- `replit_sync`
|
|
||||||
|
|
||||||
## ملاحظات تشغيلية
|
|
||||||
|
|
||||||
- الواجهة الأمامية الآن تدعم `VITE_API_BASE_URL` إذا أردت backend مختلفًا عن نفس الدومين.
|
|
||||||
- في وضع التطوير، Vite يمرر `/api` إلى `http://127.0.0.1:8080` عبر proxy.
|
|
||||||
- إذا لم تكن أسرار الـ backend موجودة، فسيستمر المتجر الأمامي بالعمل، لكن تشغيل خدمة الـ API سيتم تخطيه أثناء النشر.
|
|
||||||
@ -1 +1,2 @@
|
|||||||
export * from "./generated/api";
|
export * from "./generated/api";
|
||||||
|
export * from "./generated/types";
|
||||||
|
|||||||
@ -4,56 +4,13 @@ import * as schema from "./schema";
|
|||||||
|
|
||||||
const { Pool } = pg;
|
const { Pool } = pg;
|
||||||
|
|
||||||
const databaseUrl = process.env.DATABASE_URL;
|
if (!process.env.DATABASE_URL) {
|
||||||
|
|
||||||
if (!databaseUrl) {
|
|
||||||
throw new Error(
|
throw new Error(
|
||||||
"DATABASE_URL must be set. Did you forget to provision a database?",
|
"DATABASE_URL must be set. Did you forget to provision a database?",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function envNumber(name: string, fallback: number): number {
|
export const pool = new Pool({ connectionString: process.env.DATABASE_URL });
|
||||||
const raw = process.env[name];
|
|
||||||
if (!raw) return fallback;
|
|
||||||
const parsed = Number(raw);
|
|
||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveSsl() {
|
|
||||||
const sslEnv = (process.env.DB_SSL ?? "auto").toLowerCase();
|
|
||||||
if (sslEnv === "disable" || sslEnv === "false") {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rejectUnauthorized =
|
|
||||||
(process.env.DB_SSL_REJECT_UNAUTHORIZED ?? "false").toLowerCase() ===
|
|
||||||
"true";
|
|
||||||
|
|
||||||
if (
|
|
||||||
sslEnv === "require" ||
|
|
||||||
resolvedDatabaseUrl.includes("sslmode=require") ||
|
|
||||||
resolvedDatabaseUrl.includes("supabase.co")
|
|
||||||
) {
|
|
||||||
return { rejectUnauthorized };
|
|
||||||
}
|
|
||||||
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
const resolvedDatabaseUrl = databaseUrl;
|
|
||||||
|
|
||||||
export const pool = new Pool({
|
|
||||||
connectionString: resolvedDatabaseUrl,
|
|
||||||
max: envNumber("DB_POOL_MAX", 20),
|
|
||||||
idleTimeoutMillis: envNumber("DB_IDLE_TIMEOUT_MS", 30_000),
|
|
||||||
connectionTimeoutMillis: envNumber("DB_CONNECTION_TIMEOUT_MS", 10_000),
|
|
||||||
keepAlive: true,
|
|
||||||
application_name: process.env.DB_APP_NAME ?? "flatlogic-backend",
|
|
||||||
query_timeout: envNumber("DB_QUERY_TIMEOUT_MS", 15_000),
|
|
||||||
statement_timeout: envNumber("DB_STATEMENT_TIMEOUT_MS", 15_000),
|
|
||||||
ssl: resolveSsl(),
|
|
||||||
});
|
|
||||||
|
|
||||||
export const db = drizzle(pool, { schema });
|
export const db = drizzle(pool, { schema });
|
||||||
|
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
|||||||
@ -1,10 +1,8 @@
|
|||||||
import { pgTable, serial, text, integer, timestamp, index } from "drizzle-orm/pg-core";
|
import { pgTable, serial, text, integer, timestamp } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
|
|
||||||
export const categoriesTable = pgTable(
|
export const categoriesTable = pgTable("categories", {
|
||||||
"categories",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
name_en: text("name_en"),
|
name_en: text("name_en"),
|
||||||
@ -17,13 +15,7 @@ export const categoriesTable = pgTable(
|
|||||||
shein_cat_id: text("shein_cat_id"),
|
shein_cat_id: text("shein_cat_id"),
|
||||||
shein_url: text("shein_url"),
|
shein_url: text("shein_url"),
|
||||||
created_at: timestamp("created_at").defaultNow(),
|
created_at: timestamp("created_at").defaultNow(),
|
||||||
},
|
});
|
||||||
(table) => ({
|
|
||||||
slugIdx: index("categories_slug_idx").on(table.slug),
|
|
||||||
sourceIdx: index("categories_source_idx").on(table.source),
|
|
||||||
parentIdx: index("categories_parent_idx").on(table.parent_id),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const insertCategorySchema = createInsertSchema(categoriesTable).omit({ id: true, created_at: true });
|
export const insertCategorySchema = createInsertSchema(categoriesTable).omit({ id: true, created_at: true });
|
||||||
export type InsertCategory = z.infer<typeof insertCategorySchema>;
|
export type InsertCategory = z.infer<typeof insertCategorySchema>;
|
||||||
|
|||||||
@ -10,4 +10,3 @@ export * from "./admin";
|
|||||||
export * from "./support";
|
export * from "./support";
|
||||||
export * from "./offers";
|
export * from "./offers";
|
||||||
export * from "./users";
|
export * from "./users";
|
||||||
export * from "./integration-events";
|
|
||||||
|
|||||||
@ -1,29 +0,0 @@
|
|||||||
import { pgTable, serial, text, jsonb, timestamp, integer, index } from "drizzle-orm/pg-core";
|
|
||||||
|
|
||||||
export const integrationEventsTable = pgTable(
|
|
||||||
"integration_events",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
|
||||||
source: text("source").notNull(),
|
|
||||||
event_type: text("event_type").notNull(),
|
|
||||||
status: text("status").notNull().default("received"),
|
|
||||||
external_id: text("external_id"),
|
|
||||||
dedupe_key: text("dedupe_key"),
|
|
||||||
items_total: integer("items_total").notNull().default(0),
|
|
||||||
items_succeeded: integer("items_succeeded").notNull().default(0),
|
|
||||||
items_failed: integer("items_failed").notNull().default(0),
|
|
||||||
error: text("error"),
|
|
||||||
payload: jsonb("payload").$type<Record<string, unknown>>().default({}),
|
|
||||||
created_at: timestamp("created_at").defaultNow(),
|
|
||||||
processed_at: timestamp("processed_at"),
|
|
||||||
},
|
|
||||||
(table) => ({
|
|
||||||
sourceIdx: index("integration_events_source_idx").on(table.source),
|
|
||||||
statusIdx: index("integration_events_status_idx").on(table.status),
|
|
||||||
createdAtIdx: index("integration_events_created_at_idx").on(table.created_at),
|
|
||||||
dedupeKeyIdx: index("integration_events_dedupe_key_idx").on(table.dedupe_key),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export type IntegrationEvent = typeof integrationEventsTable.$inferSelect;
|
|
||||||
export type InsertIntegrationEvent = typeof integrationEventsTable.$inferInsert;
|
|
||||||
@ -1,4 +1,4 @@
|
|||||||
import { pgTable, serial, text, integer, numeric, boolean, jsonb, timestamp, index, uniqueIndex } from "drizzle-orm/pg-core";
|
import { pgTable, serial, text, integer, numeric, boolean, jsonb, timestamp } from "drizzle-orm/pg-core";
|
||||||
import { createInsertSchema } from "drizzle-zod";
|
import { createInsertSchema } from "drizzle-zod";
|
||||||
import { z } from "zod/v4";
|
import { z } from "zod/v4";
|
||||||
import { categoriesTable } from "./categories";
|
import { categoriesTable } from "./categories";
|
||||||
@ -10,15 +10,8 @@ export type ProductVariant = {
|
|||||||
sku?: string;
|
sku?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const productsTable = pgTable(
|
export const productsTable = pgTable("products", {
|
||||||
"products",
|
|
||||||
{
|
|
||||||
id: serial("id").primaryKey(),
|
id: serial("id").primaryKey(),
|
||||||
source: text("source").notNull().default("extra"),
|
|
||||||
external_id: text("external_id"),
|
|
||||||
source_url: text("source_url"),
|
|
||||||
currency: text("currency").notNull().default("SAR"),
|
|
||||||
availability: text("availability").notNull().default("unknown"),
|
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
name_en: text("name_en"),
|
name_en: text("name_en"),
|
||||||
short_description: text("short_description"),
|
short_description: text("short_description"),
|
||||||
@ -36,7 +29,6 @@ export const productsTable = pgTable(
|
|||||||
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
marketing_points: jsonb("marketing_points").$type<string[]>().default([]),
|
||||||
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
variants: jsonb("variants").$type<ProductVariant[]>().default([]),
|
||||||
tags: jsonb("tags").$type<string[]>().default([]),
|
tags: jsonb("tags").$type<string[]>().default([]),
|
||||||
metadata: jsonb("metadata").$type<Record<string, unknown>>().default({}),
|
|
||||||
stock: integer("stock").notNull().default(0),
|
stock: integer("stock").notNull().default(0),
|
||||||
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
rating: numeric("rating", { precision: 3, scale: 2 }).default("0"),
|
||||||
review_count: integer("review_count").default(0),
|
review_count: integer("review_count").default(0),
|
||||||
@ -44,21 +36,9 @@ export const productsTable = pgTable(
|
|||||||
is_bestseller: boolean("is_bestseller").default(false),
|
is_bestseller: boolean("is_bestseller").default(false),
|
||||||
is_new: boolean("is_new").default(true),
|
is_new: boolean("is_new").default(true),
|
||||||
is_top_rated: boolean("is_top_rated").default(false),
|
is_top_rated: boolean("is_top_rated").default(false),
|
||||||
last_synced_at: timestamp("last_synced_at").defaultNow(),
|
|
||||||
created_at: timestamp("created_at").defaultNow(),
|
created_at: timestamp("created_at").defaultNow(),
|
||||||
updated_at: timestamp("updated_at").defaultNow(),
|
updated_at: timestamp("updated_at").defaultNow(),
|
||||||
},
|
});
|
||||||
(table) => ({
|
|
||||||
sourceExternalIdUnique: uniqueIndex("products_source_external_id_uidx").on(
|
|
||||||
table.source,
|
|
||||||
table.external_id,
|
|
||||||
),
|
|
||||||
sourceSkuIdx: index("products_source_sku_idx").on(table.source, table.sku),
|
|
||||||
categoryIdx: index("products_category_idx").on(table.category_id),
|
|
||||||
brandIdx: index("products_brand_idx").on(table.brand),
|
|
||||||
updatedAtIdx: index("products_updated_at_idx").on(table.updated_at),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
export const insertProductSchema = createInsertSchema(productsTable).omit({ id: true, created_at: true, updated_at: true });
|
export const insertProductSchema = createInsertSchema(productsTable).omit({ id: true, created_at: true, updated_at: true });
|
||||||
export type InsertProduct = z.infer<typeof insertProductSchema>;
|
export type InsertProduct = z.infer<typeof insertProductSchema>;
|
||||||
|
|||||||
@ -6,11 +6,7 @@
|
|||||||
"preinstall": "sh -c 'rm -f package-lock.json yarn.lock; case \"$npm_config_user_agent\" in pnpm/*) ;; *) echo \"Use pnpm instead\" >&2; exit 1 ;; esac'",
|
"preinstall": "sh -c 'rm -f package-lock.json yarn.lock; case \"$npm_config_user_agent\" in pnpm/*) ;; *) echo \"Use pnpm instead\" >&2; exit 1 ;; esac'",
|
||||||
"build": "pnpm run typecheck && pnpm -r --if-present run build",
|
"build": "pnpm run typecheck && pnpm -r --if-present run build",
|
||||||
"typecheck:libs": "tsc --build",
|
"typecheck:libs": "tsc --build",
|
||||||
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck",
|
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck"
|
||||||
"api:build": "pnpm --filter @workspace/api-server run build",
|
|
||||||
"api:typecheck": "pnpm --filter @workspace/api-server run typecheck",
|
|
||||||
"db:push": "pnpm --filter @workspace/db run push",
|
|
||||||
"deploy:flatlogic": "bash ./scripts/flatlogic-deploy.sh"
|
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -1,58 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
PROJECT_DIR="${PROJECT_DIR:-$(pwd)}"
|
|
||||||
DEPLOY_BRANCH="${DEPLOY_BRANCH:-main}"
|
|
||||||
STORE_PORT="${STORE_PORT:-3001}"
|
|
||||||
API_PORT="${API_PORT:-8080}"
|
|
||||||
|
|
||||||
cd "$PROJECT_DIR"
|
|
||||||
|
|
||||||
echo "[deploy] Updating repository to origin/${DEPLOY_BRANCH}"
|
|
||||||
git fetch origin "$DEPLOY_BRANCH"
|
|
||||||
git checkout "$DEPLOY_BRANCH"
|
|
||||||
git reset --hard "origin/$DEPLOY_BRANCH"
|
|
||||||
|
|
||||||
echo "[deploy] Ensuring pnpm is available"
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.16.1 --activate
|
|
||||||
|
|
||||||
echo "[deploy] Installing dependencies"
|
|
||||||
pnpm install --frozen-lockfile
|
|
||||||
|
|
||||||
echo "[deploy] Running checks"
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm build
|
|
||||||
|
|
||||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
|
||||||
echo "[deploy] Applying Drizzle schema to external PostgreSQL/Supabase"
|
|
||||||
pnpm --filter @workspace/db run push
|
|
||||||
else
|
|
||||||
echo "[deploy] DATABASE_URL is missing; skipping DB schema push"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[deploy] Restarting storefront"
|
|
||||||
if pm2 describe extra-store >/dev/null 2>&1; then
|
|
||||||
PORT="$STORE_PORT" pm2 restart extra-store --update-env
|
|
||||||
else
|
|
||||||
PORT="$STORE_PORT" pm2 start pnpm --name extra-store --interpreter bash -- -lc "pnpm --filter @workspace/extra-store run dev"
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ -n "${DATABASE_URL:-}" && -n "${ADMIN_TOKEN:-}" && -n "${API_INGEST_KEY:-}" ]]; then
|
|
||||||
echo "[deploy] Restarting API backend"
|
|
||||||
if pm2 describe flatlogic-api >/dev/null 2>&1; then
|
|
||||||
PORT="$API_PORT" NODE_ENV=production pm2 restart flatlogic-api --update-env
|
|
||||||
else
|
|
||||||
PORT="$API_PORT" NODE_ENV=production pm2 start pnpm --name flatlogic-api --interpreter bash -- -lc "pnpm --filter @workspace/api-server run start"
|
|
||||||
fi
|
|
||||||
else
|
|
||||||
echo "[deploy] API secrets are incomplete; skipping API process start"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[deploy] Health checks"
|
|
||||||
curl -fsS "http://127.0.0.1:${STORE_PORT}/" >/dev/null
|
|
||||||
if pm2 describe flatlogic-api >/dev/null 2>&1; then
|
|
||||||
curl -fsS "http://127.0.0.1:${API_PORT}/api/healthz" >/dev/null
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "[deploy] Done"
|
|
||||||
12
scripts/post-merge.sh
Executable file → Normal file
12
scripts/post-merge.sh
Executable file → Normal file
@ -1,12 +1,4 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
set -euo pipefail
|
set -e
|
||||||
|
|
||||||
corepack enable
|
|
||||||
corepack prepare pnpm@10.16.1 --activate
|
|
||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
pnpm --filter db push
|
||||||
if [[ -n "${DATABASE_URL:-}" ]]; then
|
|
||||||
pnpm --filter @workspace/db run push
|
|
||||||
else
|
|
||||||
echo "DATABASE_URL is not set; skipping database push"
|
|
||||||
fi
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user