diff --git a/.env.example b/.env.example index d92d2d3..c61c7e5 100644 --- a/.env.example +++ b/.env.example @@ -12,3 +12,25 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper +APIFY_TIKTOK_ACTOR_ID=toolzerhub~tiktok-shop-products-scraper + +# Magic AI (server-side only) +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.6-luna + +# Stripe Billing (server-side only) +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_BASIC_MONTHLY= +STRIPE_PRICE_BASIC_YEARLY= +STRIPE_PRICE_STANDARD_MONTHLY= +STRIPE_PRICE_STANDARD_YEARLY= +STRIPE_PRICE_PREMIUM_MONTHLY= +STRIPE_PRICE_PREMIUM_YEARLY= + +# Daily intelligence and optional email alerts +CRON_SECRET= +RESEND_API_KEY= +ALERT_FROM_EMAIL= +ALERT_WEBHOOK_URL= +ENABLE_DEMO_ACCOUNTS=false diff --git a/.env.production.example b/.env.production.example index 9c8ec67..c740615 100644 --- a/.env.production.example +++ b/.env.production.example @@ -12,6 +12,22 @@ GOOGLE_CLIENT_SECRET= # Apify Meta Ads ingestion (runtime secret; do not expose to the browser) APIFY_TOKEN= APIFY_ACTOR_ID=aiscraperdev~facebook-meta-ads-library-scraper +APIFY_TIKTOK_ACTOR_ID=toolzerhub~tiktok-shop-products-scraper +OPENAI_API_KEY= +OPENAI_MODEL=gpt-5.6-luna +STRIPE_SECRET_KEY= +STRIPE_WEBHOOK_SECRET= +STRIPE_PRICE_BASIC_MONTHLY= +STRIPE_PRICE_BASIC_YEARLY= +STRIPE_PRICE_STANDARD_MONTHLY= +STRIPE_PRICE_STANDARD_YEARLY= +STRIPE_PRICE_PREMIUM_MONTHLY= +STRIPE_PRICE_PREMIUM_YEARLY= +CRON_SECRET= +RESEND_API_KEY= +ALERT_FROM_EMAIL= +ALERT_WEBHOOK_URL= +ENABLE_DEMO_ACCOUNTS=false # For docker-compose POSTGRES_PASSWORD="change-this-long-random-password" diff --git a/README.md b/README.md index 336c987..5670763 100644 --- a/README.md +++ b/README.md @@ -24,15 +24,8 @@ npm run db:seed npm run dev ``` -Demo hesaplar: - -```txt -Demo: -demo@winninghunter.local / demo1234 - -Admin: -admin@winninghunter.local / admin1234 -``` +Üretimde hazır demo hesabı oluşturulmaz. Yalnızca yerel geliştirmede bilinçli olarak +`ENABLE_DEMO_ACCOUNTS=true` ayarlanırsa seed hesapları korunur. ## Production build @@ -53,9 +46,16 @@ curl http://localhost:3001/login ```bash cp .env.production.example .env docker compose up -d --build -docker compose exec app npm run db:seed ``` +## Production operations + +- Health endpoint: `GET /api/health` +- Daily intelligence: `POST /api/cron/daily-intelligence` with `Authorization: Bearer $CRON_SECRET` +- PostgreSQL backup: `npm run backup` (default retention: 14 days) +- External monitor check: `npm run health:check`; optional `ALERT_WEBHOOK_URL` receives failures +- Stripe webhook: `POST /api/billing/webhook` + ## Coolify deploy 1. Yeni proje oluştur. @@ -69,15 +69,10 @@ NEXT_PUBLIC_APP_URL=https://winninghunter.your-domain.com DATABASE_URL=postgresql://... AUTH_COOKIE_NAME=wh_session AUTH_SESSION_DAYS=30 -ADMIN_EMAILS=admin@winninghunter.local +ADMIN_EMAILS=owner@example.com ``` -6. İlk deploy sonrası terminal: - -```bash -npx prisma db push -npm run db:seed -``` +6. Uygulama başlangıcında şema, plan seed'i ve `ADMIN_EMAILS` yetkileri otomatik uygulanır. ## Vercel deploy diff --git a/docker-compose.yml b/docker-compose.yml index 77d53cd..8d47db0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -24,16 +24,37 @@ services: NODE_ENV: production NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL:-http://localhost:3000} DATABASE_URL: postgresql://winninghunter:${POSTGRES_PASSWORD:-winninghunter_dev_password}@db:5432/winninghunter?schema=public - AUTH_COOKIE_NAME: wh_session - AUTH_SESSION_DAYS: 30 - ADMIN_EMAILS: admin@winninghunter.local + AUTH_COOKIE_NAME: ${AUTH_COOKIE_NAME:-wh_session} + AUTH_SESSION_DAYS: ${AUTH_SESSION_DAYS:-30} + ADMIN_EMAILS: ${ADMIN_EMAILS:-} GOOGLE_CLIENT_ID: ${GOOGLE_CLIENT_ID:-} GOOGLE_CLIENT_SECRET: ${GOOGLE_CLIENT_SECRET:-} APIFY_TOKEN: ${APIFY_TOKEN:-} + APIFY_TIKTOK_ACTOR_ID: ${APIFY_TIKTOK_ACTOR_ID:-toolzerhub~tiktok-shop-products-scraper} + OPENAI_API_KEY: ${OPENAI_API_KEY:-} + OPENAI_MODEL: ${OPENAI_MODEL:-gpt-5.6-luna} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} + STRIPE_PRICE_BASIC_MONTHLY: ${STRIPE_PRICE_BASIC_MONTHLY:-} + STRIPE_PRICE_BASIC_YEARLY: ${STRIPE_PRICE_BASIC_YEARLY:-} + STRIPE_PRICE_STANDARD_MONTHLY: ${STRIPE_PRICE_STANDARD_MONTHLY:-} + STRIPE_PRICE_STANDARD_YEARLY: ${STRIPE_PRICE_STANDARD_YEARLY:-} + STRIPE_PRICE_PREMIUM_MONTHLY: ${STRIPE_PRICE_PREMIUM_MONTHLY:-} + STRIPE_PRICE_PREMIUM_YEARLY: ${STRIPE_PRICE_PREMIUM_YEARLY:-} + CRON_SECRET: ${CRON_SECRET:-} + RESEND_API_KEY: ${RESEND_API_KEY:-} + ALERT_FROM_EMAIL: ${ALERT_FROM_EMAIL:-} + ENABLE_DEMO_ACCOUNTS: ${ENABLE_DEMO_ACCOUNTS:-false} APIFY_ACTOR_ID: ${APIFY_ACTOR_ID:-aiscraperdev~facebook-meta-ads-library-scraper} ports: - "${PORT:-3000}:3000" - command: sh -c "npx prisma db push && npm run start" + healthcheck: + test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/api/health >/dev/null || exit 1"] + interval: 30s + timeout: 5s + retries: 5 + start_period: 45s + command: npm run start:deploy volumes: winninghunter_pg: diff --git a/next.config.mjs b/next.config.mjs index 38582b7..d69588f 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,9 +1,21 @@ /** @type {import('next').NextConfig} */ const nextConfig = { reactStrictMode: true, + poweredByHeader: false, typescript: { ignoreBuildErrors: false }, turbopack: { root: process.cwd() + }, + async headers() { + const securityHeaders = [ + { key: "X-Content-Type-Options", value: "nosniff" }, + { key: "X-Frame-Options", value: "DENY" }, + { key: "Referrer-Policy", value: "strict-origin-when-cross-origin" }, + { key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=(), payment=(self)" }, + { key: "Content-Security-Policy", value: "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'; object-src 'none'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; media-src 'self' blob: https:; connect-src 'self'; font-src 'self' data:; upgrade-insecure-requests" } + ]; + if (process.env.NODE_ENV === "production") securityHeaders.push({ key: "Strict-Transport-Security", value: "max-age=63072000; includeSubDomains; preload" }); + return [{ source: "/(.*)", headers: securityHeaders }]; } }; diff --git a/package-lock.json b/package-lock.json index 0ca08c0..c25e23d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,9 +10,10 @@ "dependencies": { "@prisma/client": "5.22.0", "bcryptjs": "2.4.3", - "next": "^16.3.0-canary.79", + "next": "^16.3.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "stripe": "^22.4.0", "zod": "3.23.8" }, "devDependencies": { @@ -21,7 +22,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "autoprefixer": "10.4.20", - "postcss": "8.4.47", + "postcss": "^8.5.26", "prisma": "5.22.0", "tailwindcss": "3.4.13", "tsx": "4.23.4", @@ -42,9 +43,9 @@ } }, "node_modules/@emnapi/runtime": { - "version": "1.11.2", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", - "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz", + "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "license": "MIT", "optional": true, "dependencies": { @@ -504,9 +505,9 @@ } }, "node_modules/@img/sharp-darwin-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", - "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.3.tgz", + "integrity": "sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==", "cpu": [ "arm64" ], @@ -516,19 +517,19 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-arm64": "1.2.4" + "@img/sharp-libvips-darwin-arm64": "1.3.2" } }, "node_modules/@img/sharp-darwin-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", - "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.3.tgz", + "integrity": "sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==", "cpu": [ "x64" ], @@ -538,19 +539,38 @@ "darwin" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-darwin-x64": "1.2.4" + "@img/sharp-libvips-darwin-x64": "1.3.2" + } + }, + "node_modules/@img/sharp-freebsd-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.3.tgz", + "integrity": "sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==", + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-libvips-darwin-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", - "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.2.tgz", + "integrity": "sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==", "cpu": [ "arm64" ], @@ -564,9 +584,9 @@ } }, "node_modules/@img/sharp-libvips-darwin-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", - "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.2.tgz", + "integrity": "sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==", "cpu": [ "x64" ], @@ -580,9 +600,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", - "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.2.tgz", + "integrity": "sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==", "cpu": [ "arm" ], @@ -596,9 +616,9 @@ } }, "node_modules/@img/sharp-libvips-linux-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", - "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.2.tgz", + "integrity": "sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==", "cpu": [ "arm64" ], @@ -612,9 +632,9 @@ } }, "node_modules/@img/sharp-libvips-linux-ppc64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", - "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.2.tgz", + "integrity": "sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==", "cpu": [ "ppc64" ], @@ -628,9 +648,9 @@ } }, "node_modules/@img/sharp-libvips-linux-riscv64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", - "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.2.tgz", + "integrity": "sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==", "cpu": [ "riscv64" ], @@ -644,9 +664,9 @@ } }, "node_modules/@img/sharp-libvips-linux-s390x": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", - "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.2.tgz", + "integrity": "sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==", "cpu": [ "s390x" ], @@ -660,9 +680,9 @@ } }, "node_modules/@img/sharp-libvips-linux-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", - "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.2.tgz", + "integrity": "sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==", "cpu": [ "x64" ], @@ -676,9 +696,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-arm64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", - "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.2.tgz", + "integrity": "sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==", "cpu": [ "arm64" ], @@ -692,9 +712,9 @@ } }, "node_modules/@img/sharp-libvips-linuxmusl-x64": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", - "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.2.tgz", + "integrity": "sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==", "cpu": [ "x64" ], @@ -708,9 +728,9 @@ } }, "node_modules/@img/sharp-linux-arm": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", - "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.3.tgz", + "integrity": "sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==", "cpu": [ "arm" ], @@ -720,19 +740,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm": "1.2.4" + "@img/sharp-libvips-linux-arm": "1.3.2" } }, "node_modules/@img/sharp-linux-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", - "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.3.tgz", + "integrity": "sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==", "cpu": [ "arm64" ], @@ -742,19 +762,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-arm64": "1.2.4" + "@img/sharp-libvips-linux-arm64": "1.3.2" } }, "node_modules/@img/sharp-linux-ppc64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", - "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.3.tgz", + "integrity": "sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==", "cpu": [ "ppc64" ], @@ -764,19 +784,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-ppc64": "1.2.4" + "@img/sharp-libvips-linux-ppc64": "1.3.2" } }, "node_modules/@img/sharp-linux-riscv64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", - "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.3.tgz", + "integrity": "sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==", "cpu": [ "riscv64" ], @@ -786,19 +806,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-riscv64": "1.2.4" + "@img/sharp-libvips-linux-riscv64": "1.3.2" } }, "node_modules/@img/sharp-linux-s390x": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", - "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.3.tgz", + "integrity": "sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==", "cpu": [ "s390x" ], @@ -808,19 +828,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-s390x": "1.2.4" + "@img/sharp-libvips-linux-s390x": "1.3.2" } }, "node_modules/@img/sharp-linux-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", - "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.3.tgz", + "integrity": "sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==", "cpu": [ "x64" ], @@ -830,19 +850,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linux-x64": "1.2.4" + "@img/sharp-libvips-linux-x64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", - "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.3.tgz", + "integrity": "sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==", "cpu": [ "arm64" ], @@ -852,19 +872,19 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2" } }, "node_modules/@img/sharp-linuxmusl-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", - "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.3.tgz", + "integrity": "sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==", "cpu": [ "x64" ], @@ -874,38 +894,54 @@ "linux" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + "@img/sharp-libvips-linuxmusl-x64": "1.3.2" } }, "node_modules/@img/sharp-wasm32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", - "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", - "cpu": [ - "wasm32" - ], + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.3.tgz", + "integrity": "sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==", "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", "optional": true, "dependencies": { - "@emnapi/runtime": "^1.7.0" + "@emnapi/runtime": "^1.11.1" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-webcontainers-wasm32": { + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.3.tgz", + "integrity": "sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/sharp-wasm32": "0.35.3" + }, + "engines": { + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-arm64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", - "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.3.tgz", + "integrity": "sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==", "cpu": [ "arm64" ], @@ -915,16 +951,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-ia32": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", - "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.3.tgz", + "integrity": "sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==", "cpu": [ "ia32" ], @@ -934,16 +970,16 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": "^20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" } }, "node_modules/@img/sharp-win32-x64": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", - "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.3.tgz", + "integrity": "sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==", "cpu": [ "x64" ], @@ -953,7 +989,7 @@ "win32" ], "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" @@ -999,15 +1035,15 @@ } }, "node_modules/@next/env": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0-canary.79.tgz", - "integrity": "sha512-F2U7ygW1qpAFXMLOp2vok9V5VWR2aKoQWX9sOPYpLk4NOlkkdeY5UAt1PDfm6nMPagL81AYEauwSU6N3iwUOng==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.3.0.tgz", + "integrity": "sha512-o9r1S0BNiNreHP9Vs+Qnqd9kviDkJh8xIACY7UFZSmiGbbQRzPBBosvHzAU4TULHOIuOj/18RSsyz2qrREmIFw==", "license": "MIT" }, "node_modules/@next/swc-darwin-arm64": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0-canary.79.tgz", - "integrity": "sha512-A8I4zhxR5Dsvcbbnai1Mm/i5LxC/lm66tgI7I2tZbiLindg+lTKF7FwcAFyhnqZR89hNRGlxV0ZpCWPeQNZWEw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.3.0.tgz", + "integrity": "sha512-55hpqq18bEVAlxedlTt3tFqZmKg2nUXT1kn1G/BGEy0R13h3LwtwHPVzzjG6P4LLeOHE32PFDQUVaJEWvBEZBw==", "cpu": [ "arm64" ], @@ -1021,9 +1057,9 @@ } }, "node_modules/@next/swc-darwin-x64": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0-canary.79.tgz", - "integrity": "sha512-28a+1M3+MLUgGZqZC64vqjdonSTBNJXt60CNw4ZXCOkxPfzvp0wkvKjqL8ZYdLBBHBwTC7tUlmeEsnl7PWTXHg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.3.0.tgz", + "integrity": "sha512-SOi96kSaF5T+0wW4koiM1bWzSPwjzTesC1p3df+FjdOi5LIQkBK/blxh7HdoKnNuI4PURF1OO7TZqtfnbWDSgw==", "cpu": [ "x64" ], @@ -1037,9 +1073,9 @@ } }, "node_modules/@next/swc-linux-arm64-gnu": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0-canary.79.tgz", - "integrity": "sha512-MJ0oEhaFxnJ8LBK1zNelNqKQph+ZaWeqLpYOcFdl/aXM4PHHbv+5M5KEVKrbbdjwM2t+wdXmgQ2iozyUbdpaPw==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.3.0.tgz", + "integrity": "sha512-P0gZAoPMF4dyTRzhmkV4PrqVzSOB6t4mC1oI3c4dqijJ+OVEVx5clIXAKR4/uQpsqw2KKM/0D5tVumcR2r5blg==", "cpu": [ "arm64" ], @@ -1053,9 +1089,9 @@ } }, "node_modules/@next/swc-linux-arm64-musl": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0-canary.79.tgz", - "integrity": "sha512-CgjUs9aeJED98Zwl+bh+Ufo/M0ddPy+2BmupRt8SMaxJZa2wNNn4vognqPfHKr/pkiqr01X3hyuxNEk0CCyW5g==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.3.0.tgz", + "integrity": "sha512-tXXGKJw0m37O0eKJARVTX/TheKPhz0QFVtVVZXmOig+9YKLQOSP6hvf2pxv5DO7CLEJyTHx3Pg043CDQkv1G4Q==", "cpu": [ "arm64" ], @@ -1069,9 +1105,9 @@ } }, "node_modules/@next/swc-linux-x64-gnu": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0-canary.79.tgz", - "integrity": "sha512-s2yPLT03R/gr/+FUMMecjgjsN1gUH0M6twOvIwhEMcL00LUB/q4RlVpHsVfAZ5DT3EV8GLI/XW2qhpJFaRQEyA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.3.0.tgz", + "integrity": "sha512-pjGxK5EY7yWml78ALejFkWmgHsU7wbFQrISiugpH6FbUJhgEvw3xFZ/EBAtLl7QtL0WdQKiG9eWJ3mOKGTukHw==", "cpu": [ "x64" ], @@ -1085,9 +1121,9 @@ } }, "node_modules/@next/swc-linux-x64-musl": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0-canary.79.tgz", - "integrity": "sha512-vhbTok2qmQ/cXl4XPoiI7wVAX0iYPdgGqPaP/YgQf3eyTBfU5Ej57mdsd2LFxD2ObTMzAhAC3VidnC6oFhFnPg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.3.0.tgz", + "integrity": "sha512-sjo++Xx+lomlPs3HRsHWhVDyGG6ms1kGW5EtHLERdII8AyG1i+f6aq68xHREO6AEMlhjTNEWBSmfJfqm9orf7g==", "cpu": [ "x64" ], @@ -1101,9 +1137,9 @@ } }, "node_modules/@next/swc-win32-arm64-msvc": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0-canary.79.tgz", - "integrity": "sha512-glPd/6XP6jiflTPSCTNGbMkZ7BbNy29nk9QOainug2nwgRHr2xCPxQQNyxwtedCNKjKqXMhLGX8HWMu8PjPpEg==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.3.0.tgz", + "integrity": "sha512-C5JSgiO54wURdaxdEUIXqkz04uMqC9UmPX1gtDrV/5Tf1UowdWYI8uA5hfFbPolTlp0q4KZ60xlHePNibf0VIw==", "cpu": [ "arm64" ], @@ -1117,9 +1153,9 @@ } }, "node_modules/@next/swc-win32-x64-msvc": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0-canary.79.tgz", - "integrity": "sha512-m0BfBDihZgAHjgl7tkQVyd4jAyVGi1Y4E9taFSmcPbxqFm2gEwdRQ85xdOUBMe8FDQpwIs0xcv1obulpw3VDdA==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.3.0.tgz", + "integrity": "sha512-fDOggsweNb5SSw0ZKVk6U+gxSyGFFlIBY/LBc1r8GUj4u/6t6oArL+Pmkg0MBnsgR+KkdsURilVH4F3GXUGepA==", "cpu": [ "x64" ], @@ -1258,7 +1294,7 @@ "version": "26.1.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.0.tgz", "integrity": "sha512-O0A1G3xPGy4w7AgQdAQYUlQ+BKk2Oovw8eRpofyp5KdBZULnbe+WqaOVNrm705SHphCiG4XHsACrSmPu1f+Kgw==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "undici-types": "~8.3.0" @@ -1869,9 +1905,9 @@ } }, "node_modules/nanoid": { - "version": "3.3.15", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", - "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", "funding": [ { "type": "github", @@ -1887,16 +1923,16 @@ } }, "node_modules/next": { - "version": "16.3.0-canary.79", - "resolved": "https://registry.npmjs.org/next/-/next-16.3.0-canary.79.tgz", - "integrity": "sha512-rMWsAkNQ/+adD45tnz05D683vA/+mVBze7flZ2oV3qgSlPc5tviVlTnu18uz4eccO53yIuL8zd5sdvrMb4Us8w==", + "version": "16.3.0", + "resolved": "https://registry.npmjs.org/next/-/next-16.3.0.tgz", + "integrity": "sha512-NEdGOzH+08eTXMUp9UYkA99Nhi5N6Thrhc1jgFOQgfgnGK/dA2hRwBpXep+exdFQrnwlRf/3Wixyp8lLBUpE2A==", "license": "MIT", "dependencies": { - "@next/env": "16.3.0-canary.79", + "@next/env": "16.3.0", "@swc/helpers": "0.5.15", "baseline-browser-mapping": "^2.9.19", "caniuse-lite": "^1.0.30001579", - "postcss": "8.5.10", + "postcss": "8.5.23", "styled-jsx": "5.1.6" }, "bin": { @@ -1906,15 +1942,15 @@ "node": ">=20.9.0" }, "optionalDependencies": { - "@next/swc-darwin-arm64": "16.3.0-canary.79", - "@next/swc-darwin-x64": "16.3.0-canary.79", - "@next/swc-linux-arm64-gnu": "16.3.0-canary.79", - "@next/swc-linux-arm64-musl": "16.3.0-canary.79", - "@next/swc-linux-x64-gnu": "16.3.0-canary.79", - "@next/swc-linux-x64-musl": "16.3.0-canary.79", - "@next/swc-win32-arm64-msvc": "16.3.0-canary.79", - "@next/swc-win32-x64-msvc": "16.3.0-canary.79", - "sharp": "^0.34.5" + "@next/swc-darwin-arm64": "16.3.0", + "@next/swc-darwin-x64": "16.3.0", + "@next/swc-linux-arm64-gnu": "16.3.0", + "@next/swc-linux-arm64-musl": "16.3.0", + "@next/swc-linux-x64-gnu": "16.3.0", + "@next/swc-linux-x64-musl": "16.3.0", + "@next/swc-win32-arm64-msvc": "16.3.0", + "@next/swc-win32-x64-msvc": "16.3.0", + "sharp": "^0.35.3" }, "peerDependencies": { "@opentelemetry/api": "^1.1.0", @@ -1940,9 +1976,9 @@ } }, "node_modules/next/node_modules/postcss": { - "version": "8.5.10", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", - "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "version": "8.5.23", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz", + "integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==", "funding": [ { "type": "opencollective", @@ -1959,7 +1995,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.16", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -2064,9 +2100,9 @@ } }, "node_modules/postcss": { - "version": "8.4.47", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.47.tgz", - "integrity": "sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==", + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", "dev": true, "funding": [ { @@ -2084,8 +2120,8 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.7", - "picocolors": "^1.1.0", + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, "engines": { @@ -2345,48 +2381,53 @@ } }, "node_modules/sharp": { - "version": "0.34.5", - "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", - "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", - "hasInstallScript": true, + "version": "0.35.3", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.3.tgz", + "integrity": "sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==", "license": "Apache-2.0", "optional": true, "dependencies": { - "@img/colour": "^1.0.0", + "@img/colour": "^1.1.0", "detect-libc": "^2.1.2", - "semver": "^7.7.3" + "semver": "^7.8.5" }, "engines": { - "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + "node": ">=20.9.0" }, "funding": { "url": "https://opencollective.com/libvips" }, "optionalDependencies": { - "@img/sharp-darwin-arm64": "0.34.5", - "@img/sharp-darwin-x64": "0.34.5", - "@img/sharp-libvips-darwin-arm64": "1.2.4", - "@img/sharp-libvips-darwin-x64": "1.2.4", - "@img/sharp-libvips-linux-arm": "1.2.4", - "@img/sharp-libvips-linux-arm64": "1.2.4", - "@img/sharp-libvips-linux-ppc64": "1.2.4", - "@img/sharp-libvips-linux-riscv64": "1.2.4", - "@img/sharp-libvips-linux-s390x": "1.2.4", - "@img/sharp-libvips-linux-x64": "1.2.4", - "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", - "@img/sharp-libvips-linuxmusl-x64": "1.2.4", - "@img/sharp-linux-arm": "0.34.5", - "@img/sharp-linux-arm64": "0.34.5", - "@img/sharp-linux-ppc64": "0.34.5", - "@img/sharp-linux-riscv64": "0.34.5", - "@img/sharp-linux-s390x": "0.34.5", - "@img/sharp-linux-x64": "0.34.5", - "@img/sharp-linuxmusl-arm64": "0.34.5", - "@img/sharp-linuxmusl-x64": "0.34.5", - "@img/sharp-wasm32": "0.34.5", - "@img/sharp-win32-arm64": "0.34.5", - "@img/sharp-win32-ia32": "0.34.5", - "@img/sharp-win32-x64": "0.34.5" + "@img/sharp-darwin-arm64": "0.35.3", + "@img/sharp-darwin-x64": "0.35.3", + "@img/sharp-freebsd-wasm32": "0.35.3", + "@img/sharp-libvips-darwin-arm64": "1.3.2", + "@img/sharp-libvips-darwin-x64": "1.3.2", + "@img/sharp-libvips-linux-arm": "1.3.2", + "@img/sharp-libvips-linux-arm64": "1.3.2", + "@img/sharp-libvips-linux-ppc64": "1.3.2", + "@img/sharp-libvips-linux-riscv64": "1.3.2", + "@img/sharp-libvips-linux-s390x": "1.3.2", + "@img/sharp-libvips-linux-x64": "1.3.2", + "@img/sharp-libvips-linuxmusl-arm64": "1.3.2", + "@img/sharp-libvips-linuxmusl-x64": "1.3.2", + "@img/sharp-linux-arm": "0.35.3", + "@img/sharp-linux-arm64": "0.35.3", + "@img/sharp-linux-ppc64": "0.35.3", + "@img/sharp-linux-riscv64": "0.35.3", + "@img/sharp-linux-s390x": "0.35.3", + "@img/sharp-linux-x64": "0.35.3", + "@img/sharp-linuxmusl-arm64": "0.35.3", + "@img/sharp-linuxmusl-x64": "0.35.3", + "@img/sharp-webcontainers-wasm32": "0.35.3", + "@img/sharp-win32-arm64": "0.35.3", + "@img/sharp-win32-ia32": "0.35.3", + "@img/sharp-win32-x64": "0.35.3" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } } }, "node_modules/source-map-js": { @@ -2398,6 +2439,23 @@ "node": ">=0.10.0" } }, + "node_modules/stripe": { + "version": "22.4.0", + "resolved": "https://registry.npmjs.org/stripe/-/stripe-22.4.0.tgz", + "integrity": "sha512-LVJ+tcSYeqOSnXr3i+Kz2tZ7y0crLLdP2uwD/4wccrmEVyn0g/Heo0pF7as7rxS/sOjJzrb1lxgZY0Y0Dx1pSA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -2678,7 +2736,7 @@ "version": "8.3.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/update-browserslist-db": { diff --git a/package.json b/package.json index 2c773f1..cf142c0 100644 --- a/package.json +++ b/package.json @@ -7,12 +7,15 @@ "build": "prisma generate && next build", "start": "next start", "start:deploy": "prisma db push && tsx prisma/seed-if-empty.ts && tsx prisma/bootstrap-admins.ts && next start", - "lint": "next lint", + "lint": "tsc --noEmit", + "test": "tsx --test tests/core.test.ts", "db:generate": "prisma generate", "db:migrate": "prisma migrate dev", "db:push": "prisma db push", "db:seed": "tsx prisma/seed.ts", - "db:studio": "prisma studio" + "db:studio": "prisma studio", + "backup": "sh scripts/backup-postgres.sh", + "health:check": "sh scripts/monitor-health.sh" }, "prisma": { "seed": "tsx prisma/seed.ts" @@ -20,9 +23,10 @@ "dependencies": { "@prisma/client": "5.22.0", "bcryptjs": "2.4.3", - "next": "^16.3.0-canary.79", + "next": "^16.3.0", "react": "^19.2.7", "react-dom": "^19.2.7", + "stripe": "^22.4.0", "zod": "3.23.8" }, "devDependencies": { @@ -31,7 +35,7 @@ "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "autoprefixer": "10.4.20", - "postcss": "8.4.47", + "postcss": "^8.5.26", "prisma": "5.22.0", "tailwindcss": "3.4.13", "tsx": "4.23.4", diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c0899ed..f2b82c2 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -130,6 +130,9 @@ model User { payments Payment[] @relation("PaymentOwner") manualPayments Payment[] @relation("PaymentActor") adminAuditLogs AdminAuditLog[] @relation("AuditActor") + aiAnalyses AiAnalysis[] + brandAlerts BrandAlert[] + notifications Notification[] @@index([email]) } @@ -223,8 +226,8 @@ model BrandPage { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) - ads Ad[] + store Store? @relation(fields: [storeId], references: [id], onDelete: SetNull) + ads Ad[] trackedBy TrackedBrand[] @@unique([source, externalPageId]) @@ -527,14 +530,100 @@ model TrackedBrand { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + brandPage BrandPage @relation(fields: [brandPageId], references: [id], onDelete: Cascade) + alerts BrandAlert[] @@unique([userId, brandPageId]) @@index([userId]) @@index([brandPageId]) } +model TikTokProduct { + id String @id @default(cuid()) + externalId String @unique + title String + description String? + productUrl String? + imageUrl String? + shopName String? + shopExternalId String? + region String? + currency String? + price Float? + originalPrice Float? + soldCount Int? + rating Float? + reviewCount Int? + category String? + raw Json? + firstSeenAt DateTime @default(now()) + lastSeenAt DateTime @default(now()) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([region]) + @@index([soldCount]) + @@index([lastSeenAt]) +} + +model AiAnalysis { + id String @id @default(cuid()) + userId String + query String? + provider String + model String + summary String + insights Json + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) +} + +model TrendSnapshot { + id String @id @default(cuid()) + date DateTime @unique + metrics Json + createdAt DateTime @default(now()) + + @@index([date]) +} + +model BrandAlert { + id String @id @default(cuid()) + userId String + trackedBrandId String + adId String + type String @default("NEW_AD") + title String + details Json? + readAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + trackedBrand TrackedBrand @relation(fields: [trackedBrandId], references: [id], onDelete: Cascade) + + @@unique([userId, trackedBrandId, adId, type]) + @@index([userId, readAt, createdAt]) +} + +model Notification { + id String @id @default(cuid()) + userId String + type String + title String + body String + link String? + readAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, readAt, createdAt]) +} + model FilterPreset { id String @id @default(cuid()) userId String diff --git a/prisma/seed-if-empty.ts b/prisma/seed-if-empty.ts index c1aa603..e4761a3 100644 --- a/prisma/seed-if-empty.ts +++ b/prisma/seed-if-empty.ts @@ -30,6 +30,10 @@ async function main() { console.log("🌱 Empty database detected. Running demo seed..."); await runSeed(); + if (process.env.ENABLE_DEMO_ACCOUNTS !== "true") { + await prisma.user.deleteMany({ where: { email: { in: ["demo@winninghunter.local", "admin@winninghunter.local"] } } }); + console.log("🔒 Public demo accounts removed."); + } } main() diff --git a/scripts/backup-postgres.sh b/scripts/backup-postgres.sh new file mode 100755 index 0000000..0912a07 --- /dev/null +++ b/scripts/backup-postgres.sh @@ -0,0 +1,11 @@ +#!/bin/sh +set -eu + +backup_dir="${BACKUP_DIR:-./backups}" +mkdir -p "$backup_dir" +timestamp="$(date -u +%Y%m%dT%H%M%SZ)" +target="$backup_dir/winninghunter-$timestamp.sql.gz" + +docker compose exec -T db pg_dump -U winninghunter -d winninghunter | gzip -9 > "$target" +find "$backup_dir" -type f -name 'winninghunter-*.sql.gz' -mtime +"${BACKUP_RETENTION_DAYS:-14}" -delete +echo "Backup created: $target" diff --git a/scripts/monitor-health.sh b/scripts/monitor-health.sh new file mode 100755 index 0000000..1625edd --- /dev/null +++ b/scripts/monitor-health.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu + +app_url="${NEXT_PUBLIC_APP_URL:?NEXT_PUBLIC_APP_URL is required}" +if curl --fail --silent --show-error --max-time 10 "${app_url%/}/api/health" >/dev/null; then + echo "WinningHunter healthcheck OK" + exit 0 +fi + +if [ -n "${ALERT_WEBHOOK_URL:-}" ]; then + curl --fail --silent --show-error --max-time 10 -X POST -H 'content-type: application/json' --data '{"text":"WinningHunter healthcheck failed"}' "$ALERT_WEBHOOK_URL" >/dev/null || true +fi +echo "WinningHunter healthcheck FAILED" >&2 +exit 1 diff --git a/src/app/api/admin/ingest/seed-demo/route.ts b/src/app/api/admin/ingest/seed-demo/route.ts deleted file mode 100644 index 833c40e..0000000 --- a/src/app/api/admin/ingest/seed-demo/route.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { NextResponse } from "next/server"; -import { getApiAdmin } from "@/lib/admin-api"; - -export async function POST() { - const admin = await getApiAdmin(); - if (!admin) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 }); - return NextResponse.json({ ok: true, message: "Demo seed komut satırından çalışır: npm run db:seed" }); -} diff --git a/src/app/api/ads/import-apify/route.ts b/src/app/api/ads/import-apify/route.ts new file mode 100644 index 0000000..5a7289b --- /dev/null +++ b/src/app/api/ads/import-apify/route.ts @@ -0,0 +1,97 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { currentUser } from "@/lib/auth/current-user"; +import { importApifyAds, runApifyActor } from "@/lib/apify"; +import { prisma } from "@/lib/db"; +import { planFromUser } from "@/lib/plans"; +import { checkAndConsumeQuota, refundQuota } from "@/lib/quota"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; + +const schema = z.object({ + searchTerm: z.string().trim().min(2).max(100), + maxResults: z.coerce.number().int().refine((value) => [10, 25, 50, 100].includes(value)) +}); + +function planResultLimit(planCode: string | undefined, isAdmin: boolean) { + if (isAdmin || planCode === "PREMIUM") return 100; + if (planCode === "STANDARD") return 50; + if (planCode === "BASIC") return 25; + return 0; +} + +export async function POST(request: Request) { + const limited = hizSiniriAsimi(request, "arama"); + if (limited) return limited; + + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + + const plan = planFromUser(user as never); + const isAdmin = user.role === "ADMIN"; + const resultLimit = planResultLimit(plan?.code, isAdmin); + if (resultLimit === 0) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 }); + if (parsed.data.maxResults > resultLimit) { + return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: resultLimit }, { status: 403 }); + } + + let dailyReserved = false; + let creditsReserved = false; + if (!isAdmin) { + const daily = await checkAndConsumeQuota({ userId: user.id, plan, metric: "ads_search_daily" }); + if (!daily.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: daily }, { status: 403 }); + dailyReserved = true; + + const credits = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults }); + if (!credits.allowed) { + await refundQuota({ userId: user.id, metric: "ads_search_daily" }); + return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: credits }, { status: 403 }); + } + creditsReserved = true; + } + + const job = await prisma.ingestJob.create({ + data: { + source: "apify", + type: "meta-ads-library", + status: "RUNNING", + startedAt: new Date(), + metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults } + } + }); + + try { + const records = await runApifyActor({ + searchTerms: [parsed.data.searchTerm], + country: "ALL", + adActiveStatus: "ACTIVE", + mediaType: "ALL", + maxResults: parsed.data.maxResults, + maxCostUsd: Math.max(0.1, Math.ceil(parsed.data.maxResults * 0.004 * 10) / 10), + scrapeAdDetails: true, + includeAboutPage: false + }); + const result = await importApifyAds(records); + if (creditsReserved && records.length < parsed.data.maxResults) { + await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - records.length }); + } + await prisma.ingestJob.update({ + where: { id: job.id }, + data: { + status: "COMPLETED", + finishedAt: new Date(), + recordsImported: result.imported, + recordsFailed: result.failed, + metadata: { userId: user.id, searchTerm: parsed.data.searchTerm, maxResults: parsed.data.maxResults, received: records.length } + } + }); + return NextResponse.json({ ok: true, ...result, received: records.length }); + } catch (error) { + if (dailyReserved) await refundQuota({ userId: user.id, metric: "ads_search_daily" }); + if (creditsReserved) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults }); + const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_INGEST_FAILED"; + await prisma.ingestJob.update({ where: { id: job.id }, data: { status: "FAILED", finishedAt: new Date(), errorMessage: code } }); + return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 }); + } +} diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts index ace45d3..986d1dd 100644 --- a/src/app/api/auth/login/route.ts +++ b/src/app/api/auth/login/route.ts @@ -5,10 +5,12 @@ import { prisma } from "@/lib/db"; import { createSession } from "@/lib/auth/session"; import { hizSiniriAsimi } from "@/lib/rate-limit"; -const schema = z.object({ email: z.string().email(), password: z.string().min(1) }); +const schema = z.object({ email: z.string().email(), password: z.string().min(1).max(72) }); export async function POST(req: Request) { - const body = schema.parse(await req.json()); + const parsed = schema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + const body = parsed.data; // Sayaç e-posta bazlı da tutulur: tek IP'den farklı hesaplara saldırı da yavaşlar. const sinir = hizSiniriAsimi(req, "giris", body.email.trim().toLowerCase()); if (sinir) return sinir; diff --git a/src/app/api/auth/register/route.ts b/src/app/api/auth/register/route.ts index b455e0a..c3bd77d 100644 --- a/src/app/api/auth/register/route.ts +++ b/src/app/api/auth/register/route.ts @@ -9,7 +9,7 @@ import { hizSiniriAsimi } from "@/lib/rate-limit"; const schema = z.object({ email: z.string().email(), - password: z.string().min(6), + password: z.string().min(10).max(72), name: z.string().optional() }); @@ -18,7 +18,9 @@ export async function POST(req: Request) { const sinir = hizSiniriAsimi(req, "kayit"); if (sinir) return sinir; - const body = schema.parse(await req.json()); + const parsed = schema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + const body = parsed.data; const email = body.email.trim().toLowerCase(); const isAdmin = isConfiguredAdminEmail(email); const plan = await prisma.plan.findUnique({ where: { code: isAdmin ? "PREMIUM" : "FREE" } }); diff --git a/src/app/api/billing/checkout/route.ts b/src/app/api/billing/checkout/route.ts new file mode 100644 index 0000000..76e8de1 --- /dev/null +++ b/src/app/api/billing/checkout/route.ts @@ -0,0 +1,45 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { currentUser } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; +import { stripeClient, stripePriceFor } from "@/lib/stripe"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; + +const schema = z.object({ planCode: z.enum(["BASIC", "STANDARD", "PREMIUM"]), interval: z.enum(["monthly", "yearly"]).default("monthly") }); + +export async function POST(request: Request) { + const limited = hizSiniriAsimi(request, "giris"); + if (limited) return limited; + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + const plan = await prisma.plan.findUnique({ where: { code: parsed.data.planCode } }); + if (!plan || !plan.isActive) return NextResponse.json({ error: "PLAN_NOT_FOUND" }, { status: 404 }); + try { + const stripe = stripeClient(); + const appUrl = process.env.NEXT_PUBLIC_APP_URL?.replace(/\/$/, ""); + if (!appUrl || !appUrl.startsWith("https://")) throw new Error("APP_URL_INVALID"); + const session = await stripe.checkout.sessions.create({ + mode: "subscription", + line_items: [{ price: stripePriceFor(plan.code, parsed.data.interval), quantity: 1 }], + customer: user.subscription?.providerCustomerId || undefined, + customer_email: user.subscription?.providerCustomerId ? undefined : user.email, + client_reference_id: user.id, + success_url: `${appUrl}/dashboard/account?checkout=success`, + cancel_url: `${appUrl}/pricing?checkout=canceled`, + allow_promotion_codes: true, + metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval }, + subscription_data: { metadata: { userId: user.id, planCode: plan.code, billingInterval: parsed.data.interval } } + }); + await prisma.payment.upsert({ + where: { externalId: session.id }, + create: { userId: user.id, amountCents: parsed.data.interval === "yearly" ? (plan.yearlyPriceEur || plan.monthlyPriceEur * 12) * 100 : plan.monthlyPriceEur * 100, currency: "EUR", status: "PENDING", provider: "STRIPE", externalId: session.id, description: `${plan.name} ${parsed.data.interval}` }, + update: {} + }); + return NextResponse.json({ url: session.url }); + } catch (error) { + const code = error instanceof Error && /^(STRIPE_[A-Z0-9_]+|APP_URL_INVALID)$/.test(error.message) ? error.message : "CHECKOUT_FAILED"; + return NextResponse.json({ error: code }, { status: code.includes("NOT_CONFIGURED") ? 503 : 502 }); + } +} diff --git a/src/app/api/billing/webhook/route.ts b/src/app/api/billing/webhook/route.ts new file mode 100644 index 0000000..6acdcd1 --- /dev/null +++ b/src/app/api/billing/webhook/route.ts @@ -0,0 +1,54 @@ +import { NextResponse } from "next/server"; +import Stripe from "stripe"; +import { PaymentStatus, SubscriptionStatus } from "@prisma/client"; +import { prisma } from "@/lib/db"; +import { stripeClient } from "@/lib/stripe"; + +function status(value: Stripe.Subscription.Status): SubscriptionStatus { + if (value === "active") return SubscriptionStatus.ACTIVE; + if (value === "trialing") return SubscriptionStatus.TRIALING; + if (value === "past_due" || value === "unpaid" || value === "incomplete") return SubscriptionStatus.PAST_DUE; + if (value === "canceled" || value === "incomplete_expired") return SubscriptionStatus.CANCELED; + return SubscriptionStatus.EXPIRED; +} + +async function syncSubscription(subscription: Stripe.Subscription) { + const userId = subscription.metadata.userId; + const planCode = subscription.metadata.planCode as "BASIC" | "STANDARD" | "PREMIUM" | undefined; + if (!userId || !planCode) return; + const plan = await prisma.plan.findUnique({ where: { code: planCode } }); + if (!plan) return; + const item = subscription.items.data[0]; + const start = item?.current_period_start ? new Date(item.current_period_start * 1000) : null; + const end = item?.current_period_end ? new Date(item.current_period_end * 1000) : null; + await prisma.subscription.upsert({ + where: { userId }, + create: { userId, planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id }, + update: { planId: plan.id, status: status(subscription.status), billingInterval: subscription.metadata.billingInterval || "monthly", currentPeriodStart: start, currentPeriodEnd: end, cancelAtPeriodEnd: subscription.cancel_at_period_end, provider: "STRIPE", providerCustomerId: String(subscription.customer), providerSubscriptionId: subscription.id } + }); +} + +export async function POST(request: Request) { + const secret = process.env.STRIPE_WEBHOOK_SECRET?.trim(); + const signature = request.headers.get("stripe-signature"); + if (!secret || !signature) return NextResponse.json({ error: "WEBHOOK_NOT_CONFIGURED" }, { status: 503 }); + const raw = await request.text(); + let event: Stripe.Event; + try { event = stripeClient().webhooks.constructEvent(raw, signature, secret, 300); } + catch { return NextResponse.json({ error: "INVALID_SIGNATURE" }, { status: 400 }); } + + if (event.type === "checkout.session.completed") { + const session = event.data.object; + const userId = session.metadata?.userId || session.client_reference_id; + if (userId) { + await prisma.payment.upsert({ + where: { externalId: session.id }, + create: { userId, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), status: PaymentStatus.PAID, provider: "STRIPE", externalId: session.id, description: `Stripe ${session.metadata?.planCode || "subscription"}`, paidAt: new Date() }, + update: { status: PaymentStatus.PAID, amountCents: session.amount_total || 0, currency: (session.currency || "eur").toUpperCase(), paidAt: new Date() } + }); + if (typeof session.subscription === "string") await syncSubscription(await stripeClient().subscriptions.retrieve(session.subscription)); + } + } + if (event.type === "customer.subscription.updated" || event.type === "customer.subscription.deleted" || event.type === "customer.subscription.created") await syncSubscription(event.data.object); + return NextResponse.json({ received: true }); +} diff --git a/src/app/api/cron/daily-intelligence/route.ts b/src/app/api/cron/daily-intelligence/route.ts new file mode 100644 index 0000000..11ce2fe --- /dev/null +++ b/src/app/api/cron/daily-intelligence/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { timingSafeEqual } from "node:crypto"; +import { getApiAdmin } from "@/lib/admin-api"; +import { captureTrendSnapshot, createBrandAlerts } from "@/lib/trend-snapshots"; + +export async function POST(request: Request) { + const configured = process.env.CRON_SECRET?.trim(); + const bearer = request.headers.get("authorization")?.replace(/^Bearer\s+/i, ""); + const authorizedBySecret = Boolean(configured && bearer && bearer.length === configured.length && timingSafeEqual(Buffer.from(bearer), Buffer.from(configured))); + if (!authorizedBySecret && !(await getApiAdmin())) return NextResponse.json({ error: "FORBIDDEN" }, { status: 403 }); + const snapshot = await captureTrendSnapshot(); + const alertsCreated = await createBrandAlerts(); + return NextResponse.json({ ok: true, snapshotDate: snapshot.date, alertsCreated }); +} diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts new file mode 100644 index 0000000..4d755f9 --- /dev/null +++ b/src/app/api/health/route.ts @@ -0,0 +1,12 @@ +import { NextResponse } from "next/server"; +import { prisma } from "@/lib/db"; + +export async function GET() { + const started = Date.now(); + try { + await prisma.$queryRaw`SELECT 1`; + return NextResponse.json({ status: "ok", database: "ok", latencyMs: Date.now() - started }, { headers: { "cache-control": "no-store" } }); + } catch { + return NextResponse.json({ status: "degraded", database: "unavailable" }, { status: 503, headers: { "cache-control": "no-store" } }); + } +} diff --git a/src/app/api/magic-ai/analyze/route.ts b/src/app/api/magic-ai/analyze/route.ts new file mode 100644 index 0000000..97687da --- /dev/null +++ b/src/app/api/magic-ai/analyze/route.ts @@ -0,0 +1,30 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { Prisma } from "@prisma/client"; +import { currentUser } from "@/lib/auth/current-user"; +import { prisma } from "@/lib/db"; +import { analyzeCreatives } from "@/lib/openai-analysis"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; + +const schema = z.object({ query: z.string().trim().max(100).optional() }); + +export async function POST(request: Request) { + const limited = hizSiniriAsimi(request, "arama"); + if (limited) return limited; + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => ({}))); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + const q = parsed.data.query; + const where: Prisma.AdWhereInput = q ? { OR: [{ headline: { contains: q, mode: "insensitive" } }, { primaryText: { contains: q, mode: "insensitive" } }, { brandPage: { name: { contains: q, mode: "insensitive" } } }] } : {}; + const ads = await prisma.ad.findMany({ where, include: { brandPage: true }, orderBy: [{ daysRunning: "desc" }, { updatedAt: "desc" }], take: 20 }); + if (!ads.length) return NextResponse.json({ error: "NO_ADS_TO_ANALYZE" }, { status: 404 }); + try { + const result = await analyzeCreatives(q, ads.map((ad) => ({ headline: ad.headline, primaryText: ad.primaryText, mediaType: ad.mediaType, daysRunning: ad.daysRunning, status: ad.status, brand: ad.brandPage?.name || null }))); + const saved = await prisma.aiAnalysis.create({ data: { userId: user.id, query: q, provider: "openai", model: result.model, summary: result.summary, insights: result.insights } }); + return NextResponse.json({ data: saved }); + } catch (error) { + const code = error instanceof Error && /^(OPENAI_[A-Z0-9_]+)$/.test(error.message) ? error.message : "AI_ANALYSIS_FAILED"; + return NextResponse.json({ error: code }, { status: code === "OPENAI_NOT_CONFIGURED" ? 503 : 502 }); + } +} diff --git a/src/app/api/tiktok-shop/import/route.ts b/src/app/api/tiktok-shop/import/route.ts new file mode 100644 index 0000000..68ee5a8 --- /dev/null +++ b/src/app/api/tiktok-shop/import/route.ts @@ -0,0 +1,44 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { currentUser } from "@/lib/auth/current-user"; +import { importTikTokProducts, runTikTokShopActor } from "@/lib/apify-tiktok"; +import { planFromUser } from "@/lib/plans"; +import { checkAndConsumeQuota, refundQuota } from "@/lib/quota"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; + +const schema = z.object({ query: z.string().trim().min(2).max(100), region: z.enum(["US", "GB", "DE", "FR", "TR"]).default("US"), maxResults: z.coerce.number().int().refine((v) => [10, 25, 50].includes(v)) }); + +export async function POST(request: Request) { + const limited = hizSiniriAsimi(request, "arama"); + if (limited) return limited; + const user = await currentUser(); + if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); + const plan = planFromUser(user as never); + const isAdmin = user.role === "ADMIN"; + if (!isAdmin && (!plan || plan.code === "FREE")) return NextResponse.json({ error: "UPGRADE_REQUIRED" }, { status: 403 }); + const max = isAdmin || plan?.code === "PREMIUM" ? 50 : plan?.code === "STANDARD" ? 25 : 10; + if (parsed.data.maxResults > max) return NextResponse.json({ error: "PLAN_LIMIT_EXCEEDED", maxResults: max }, { status: 403 }); + let daily = false; + let credits = false; + if (!isAdmin) { + const dailyQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "tiktok_search_daily" }); + if (!dailyQuota.allowed) return NextResponse.json({ error: "DAILY_QUOTA_EXCEEDED", usage: dailyQuota }, { status: 403 }); + daily = true; + const apiQuota = await checkAndConsumeQuota({ userId: user.id, plan, metric: "api_credits_monthly", amount: parsed.data.maxResults }); + if (!apiQuota.allowed) { await refundQuota({ userId: user.id, metric: "tiktok_search_daily" }); return NextResponse.json({ error: "API_CREDITS_EXCEEDED", usage: apiQuota }, { status: 403 }); } + credits = true; + } + try { + const rows = await runTikTokShopActor(parsed.data); + const result = await importTikTokProducts(rows, parsed.data.region); + if (credits && rows.length < parsed.data.maxResults) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults - rows.length }); + return NextResponse.json({ ok: true, ...result, received: rows.length }); + } catch (error) { + if (daily) await refundQuota({ userId: user.id, metric: "tiktok_search_daily" }); + if (credits) await refundQuota({ userId: user.id, metric: "api_credits_monthly", amount: parsed.data.maxResults }); + const code = error instanceof Error && /^APIFY_[A-Z0-9_]+$/.test(error.message) ? error.message : "APIFY_TIKTOK_FAILED"; + return NextResponse.json({ error: code }, { status: code === "APIFY_NOT_CONFIGURED" ? 503 : 502 }); + } +} diff --git a/src/app/api/tracked-stores/route.ts b/src/app/api/tracked-stores/route.ts index 5b39a7a..b65cc88 100644 --- a/src/app/api/tracked-stores/route.ts +++ b/src/app/api/tracked-stores/route.ts @@ -2,6 +2,10 @@ import { NextResponse } from "next/server"; import { prisma } from "@/lib/db"; import { currentUser } from "@/lib/auth/current-user"; import { getFeatureLimit, planFromUser } from "@/lib/plans"; +import { z } from "zod"; +import { hizSiniriAsimi } from "@/lib/rate-limit"; + +const schema = z.object({ domain: z.string().trim().min(3).max(253) }); export async function GET() { const user = await currentUser(); @@ -11,14 +15,18 @@ export async function GET() { } export async function POST(req: Request) { + const rateLimit = hizSiniriAsimi(req, "arama"); + if (rateLimit) return rateLimit; const user = await currentUser(); if (!user) return NextResponse.json({ error: "UNAUTHORIZED" }, { status: 401 }); + const parsed = schema.safeParse(await req.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "INVALID_INPUT" }, { status: 400 }); const plan = planFromUser(user as any); const limit = getFeatureLimit(plan as any, "tracked_stores"); const count = await prisma.trackedStore.count({ where: { userId: user.id } }); if (limit !== null && count >= limit) return NextResponse.json({ error: "TRACKED_STORE_LIMIT_EXCEEDED", upgradeRequired: true }, { status: 403 }); - const body = await req.json(); - const domain = String(body.domain || "").replace(/^https?:\/\//, "").replace(/\/$/, "").toLowerCase(); + let domain = parsed.data.domain.replace(/^https?:\/\//i, "").split("/")[0]!.toLowerCase().replace(/^www\./, ""); + try { domain = new URL(`https://${domain}`).hostname; } catch { return NextResponse.json({ error: "INVALID_DOMAIN" }, { status: 400 }); } const store = await prisma.store.findUnique({ where: { domain } }); if (!store) return NextResponse.json({ error: "STORE_NOT_FOUND" }, { status: 404 }); const row = await prisma.trackedStore.upsert({ where: { userId_storeId: { userId: user.id, storeId: store.id } }, update: {}, create: { userId: user.id, storeId: store.id } }); diff --git a/src/app/dashboard/ads/page.tsx b/src/app/dashboard/ads/page.tsx index 97fe278..ab793b4 100644 --- a/src/app/dashboard/ads/page.tsx +++ b/src/app/dashboard/ads/page.tsx @@ -21,6 +21,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< if (mediaType) where.mediaType = mediaType as any; const ads = await prisma.ad.findMany({ where, include: { brandPage: true, creatives: { take: 1 }, savedBy: { where: { userId: user.id } } }, orderBy: { rankPercentile: "asc" }, take: 48 }); const isAdmin = user.role === "ADMIN"; + const apifyPlanLimit = isAdmin || plan?.code === "PREMIUM" ? 100 : plan?.code === "STANDARD" ? 50 : plan?.code === "BASIC" ? 25 : 0; const masked = ads.map((ad) => maskAdForPlan({ ...ad, isSaved: ad.savedBy.length > 0 }, plan?.code, isAdmin)); return ( @@ -46,13 +47,7 @@ export default async function AdsPage({ searchParams }: { searchParams: Promise< {["Week's biggest winners", "US winners", "Dropship Ads", "Supplements", "Top Branded"].map((x) => {x})}
Yeni reklamların içe aktarılması için yöneticinizle iletişime geçin.
-{alert.title}
Kreatif dayanıklılığı, formatı ve reklam metnini analiz ederek uygulanabilir test önerileri üretir.
{analysis.summary}
{item.action}
Öneriler canlı reklam süresi ve mevcut kreatif sinyallerinden hesaplanır.
{ad.brandPage?.name || "Bilinmeyen marka"} · {ad.mediaType} · {ad.daysRunning || "—"} gün
{sentence(ad.primaryText)}
{insight.text}
Plan limitine göre rakip Shopify mağazalarını watchlist’e ekle.
TikTok Pixel bulunan mağazalardaki ürünleri büyüme, trafik, fiyat ve bestseller sinyalleriyle sırala.
{product.store.name} · {product.store.country}
Apify üzerinden canlı ürün, satış, fiyat, mağaza ve değerlendirme sinyalleri.
{product.shopName || "Mağaza bilinmiyor"} · {product.region}
Aylık ziyaret büyümesine göre
Günlük otomasyonla kaydedilen reklam ve TikTok Shop sinyalleri
{metrics.ads?.active || 0} aktif reklam
{metrics.tiktok?.total || 0} TikTok ürün
İlk günlük snapshot henüz oluşmadı.
}Apify · Yalnızca yöneticiler
+Apify · Plan limiti: {planLimit || "erişim yok"} reklam · Kullanılan kayıt kadar API kredisi
{message &&{message}
}{error}
}>; +} diff --git a/src/components/dashboard-mobile-nav.tsx b/src/components/dashboard-mobile-nav.tsx new file mode 100644 index 0000000..bbe282f --- /dev/null +++ b/src/components/dashboard-mobile-nav.tsx @@ -0,0 +1,16 @@ +"use client"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; +import { useState } from "react"; + +const items = [ + ["Ads", "/dashboard/ads"], ["Stores", "/dashboard/stores"], ["Store Tracker", "/dashboard/store-tracker"], + ["Saved Ads", "/dashboard/saved-ads"], ["TikTok Shop", "/dashboard/tiktok-shop"], ["Magic AI", "/dashboard/magic-ai"], + ["Trends", "/dashboard/trends"], ["Brand Tracker", "/dashboard/brand-tracker"], ["Account", "/dashboard/account"] +]; + +export function DashboardMobileNav({ isAdmin }: { isAdmin: boolean }) { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + return{message}
}{message}
} +${brand} için yeni reklam bulundu.
${title}
` }) }); + return response.ok; +} + +export async function createBrandAlerts() { + const tracked = await prisma.trackedBrand.findMany({ include: { user: { select: { email: true } }, brandPage: { include: { ads: { orderBy: { createdAt: "desc" }, take: 20 } } } } }); + let created = 0; + for (const item of tracked) { + for (const ad of item.brandPage.ads) { + if (ad.createdAt < item.createdAt && (!ad.firstSeenAt || ad.firstSeenAt < item.createdAt)) continue; + const title = ad.headline || ad.primaryText?.slice(0, 100) || "Yeni kreatif"; + const unique = { userId: item.userId, trackedBrandId: item.id, adId: ad.id, type: "NEW_AD" }; + if (await prisma.brandAlert.findUnique({ where: { userId_trackedBrandId_adId_type: unique } })) continue; + await prisma.brandAlert.create({ data: { ...unique, title, details: { brand: item.brandPage.name, mediaType: ad.mediaType } } }); + await prisma.notification.create({ data: { userId: item.userId, type: "BRAND_NEW_AD", title: `${item.brandPage.name}: yeni reklam`, body: title, link: "/dashboard/brand-tracker" } }); + await sendAlertEmail(item.user.email, title, item.brandPage.name).catch(() => false); + created += 1; + } + } + return created; +} diff --git a/tests/core.test.ts b/tests/core.test.ts new file mode 100644 index 0000000..d051b16 --- /dev/null +++ b/tests/core.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { MediaType } from "@prisma/client"; +import { normalizeApifyAd } from "../src/lib/apify"; +import { getFeatureLimit } from "../src/lib/plans"; +import { istemciIp, hizSiniriAsimi } from "../src/lib/rate-limit"; +import { stripePriceFor } from "../src/lib/stripe"; + +test("Apify reklamı metin, medya ve ülke alanlarıyla normalize edilir", () => { + const ad = normalizeApifyAd({ + adArchiveID: "123", + pageID: "page-1", + pageName: "Berber İstanbul", + adText: "Yeni stilini keşfet", + videoUrl: "https://cdn.example.com/ad.mp4", + countries: ["TR"], + adStatus: "ACTIVE" + }); + assert.ok(ad); + assert.equal(ad.externalAdId, "123"); + assert.equal(ad.mediaType, MediaType.VIDEO); + assert.deepEqual(ad.countries, ["TR"]); +}); + +test("plan feature limiti metrik adına göre çözülür", () => { + const plan = { features: { adsSearchDaily: 100, apiCreditsMonthly: 500 } } as never; + assert.equal(getFeatureLimit(plan, "ads_search_daily"), 100); + assert.equal(getFeatureLimit(plan, "api_credits_monthly"), 500); + assert.equal(getFeatureLimit(plan, "unknown"), 0); +}); + +test("istemci IP'sinde güvenilir son proxy adresi kullanılır", () => { + const request = new Request("https://example.com", { headers: { "x-forwarded-for": "198.51.100.1, 10.0.0.2" } }); + assert.equal(istemciIp(request), "10.0.0.2"); +}); + +test("giriş hız sınırı on birinci isteği engeller", () => { + const request = new Request("https://example.com", { headers: { "x-real-ip": `test-${Date.now()}` } }); + for (let i = 0; i < 10; i += 1) assert.equal(hizSiniriAsimi(request, "giris"), null); + assert.equal(hizSiniriAsimi(request, "giris")?.status, 429); +}); + +test("Stripe fiyat kimliği env allowlist'inden okunur", () => { + const previous = process.env.STRIPE_PRICE_BASIC_MONTHLY; + process.env.STRIPE_PRICE_BASIC_MONTHLY = "price_test123"; + assert.equal(stripePriceFor("BASIC", "monthly"), "price_test123"); + process.env.STRIPE_PRICE_BASIC_MONTHLY = "invalid"; + assert.throws(() => stripePriceFor("BASIC", "monthly"), /STRIPE_PRICE_NOT_CONFIGURED/); + if (previous === undefined) delete process.env.STRIPE_PRICE_BASIC_MONTHLY; + else process.env.STRIPE_PRICE_BASIC_MONTHLY = previous; +});