diff --git a/.dockerignore b/.dockerignore index c3b48fc..232376b 100644 --- a/.dockerignore +++ b/.dockerignore @@ -2,6 +2,7 @@ node_modules .next out +dist coverage *.tsbuildinfo next-env.d.ts diff --git a/.gitignore b/.gitignore index 56ea362..5eca11d 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ next-env.d.ts # local tooling /scratch/ +/dist diff --git a/.prettierignore b/.prettierignore index f37c117..218f411 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,3 +5,4 @@ coverage uploads next-env.d.ts docs/PROMPT.md +dist/ diff --git a/Dockerfile b/Dockerfile index 61b686e..020273f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,7 @@ # BÜTÇEM — çok aşamalı üretim imajı (Next.js standalone çıktı). # Build: docker build --build-arg APP_COMMIT=$(git rev-parse --short HEAD) -t butcem . # Çalıştır: docker run -p 3000:3000 --env-file .env.production -v butcem-uploads:/app/uploads butcem +# Başlarken: replica set kontrolü, bekleyen migration'lar, ilk admin (docker-entrypoint.sh). ARG NODE_VERSION=26 @@ -22,7 +23,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 \ APP_COMMIT=${APP_COMMIT} COPY --from=deps /app/node_modules ./node_modules COPY . . -RUN npm run build +# Uygulama + başlatma betiği (migration ve ilk admin; tek dosya, tsx gerekmez). +RUN npm run build && npm run build:scripts # 3) Çalışma zamanı — yalnızca standalone çıktı, root olmayan kullanıcı. FROM node:${NODE_VERSION}-alpine AS runner @@ -43,12 +45,15 @@ RUN addgroup -S -g 1001 nodejs && adduser -S -u 1001 -G nodejs nextjs \ COPY --from=builder --chown=nextjs:nodejs /app/public ./public COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder --chown=nextjs:nodejs /app/dist/bootstrap.mjs ./scripts/bootstrap.mjs +COPY --chown=nextjs:nodejs --chmod=755 docker-entrypoint.sh ./docker-entrypoint.sh USER nextjs VOLUME ["/app/uploads"] EXPOSE 3000 -HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ +# start-period: ilk deploy'da migration'lar için pay. +HEALTHCHECK --interval=30s --timeout=5s --start-period=60s --retries=3 \ CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" -CMD ["node", "server.js"] +ENTRYPOINT ["/app/docker-entrypoint.sh"] diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100755 index 0000000..962f93e --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +# BÜTÇEM konteyner girişi: önce başlatma (replica set kontrolü, migration'lar, ilk admin), +# başarılıysa uygulama. Başlatma başarısız olursa konteyner durur; Dokploy günlüğünde sebep görünür. +# Acil durumda atlamak için: SKIP_BOOTSTRAP=1 +set -e +if [ "${SKIP_BOOTSTRAP:-0}" != "1" ]; then + node /app/scripts/bootstrap.mjs +fi +exec node /app/server.js diff --git a/eslint.config.mjs b/eslint.config.mjs index a301bdb..0b49407 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -20,6 +20,8 @@ const eslintConfig = defineConfig([ ".next/**", "out/**", "build/**", + // Derlenmiş başlatma betiği (esbuild paketi). + "dist/**", "next-env.d.ts", ]), ]); diff --git a/package.json b/package.json index c06e0d2..8b75199 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "butcem", - "version": "0.14.0", + "version": "1.0.0", "private": true, "description": "BÜTÇEM — aile bütçe, cari, birikim, ev stoğu ve akıllı alışveriş uygulaması", "engines": { @@ -23,6 +23,8 @@ "icons": "tsx scripts/generate-icons.ts", "test:integration": "vitest run --config vitest.integration.config.mts", "migrate": "tsx --conditions=react-server --env-file=.env.local scripts/migrate.ts", + "bootstrap": "tsx --conditions=react-server --env-file=.env.local scripts/bootstrap.ts", + "build:scripts": "esbuild scripts/bootstrap.ts --bundle --platform=node --format=esm --target=node22 --conditions=react-server --outfile=dist/bootstrap.mjs --external:sharp --external:kerberos --external:snappy --external:socks --external:aws4 --external:@mongodb-js/zstd --external:mongodb-client-encryption --external:gcp-metadata --external:@aws-sdk/credential-providers --banner:js=\"import { createRequire } from 'node:module'; const require = createRequire(import.meta.url);\" --log-level=warning", "cleanup": "tsx --conditions=react-server --env-file=.env.local scripts/cleanup.ts" }, "dependencies": { diff --git a/scripts/bootstrap.ts b/scripts/bootstrap.ts new file mode 100644 index 0000000..3311dfd --- /dev/null +++ b/scripts/bootstrap.ts @@ -0,0 +1,43 @@ +/** + * Canlı başlatma (Docker entrypoint, uygulamadan önce): replica set kontrolü, bekleyen migration'lar + * ve SEED_ADMIN_* tanımlıysa ilk admin hesabı (varsa dokunulmaz). İdempotent; her deploy'da çalışır. + * İmajda esbuild ile tek dosyaya derlenir (scripts/bootstrap.mjs). Lokal: npm run bootstrap + */ +import mongoose from "mongoose"; +import { getDb, getMongoClient } from "@/lib/db/mongo"; +import { env } from "@/lib/env"; +import { runMigrations } from "./migrations"; +import { closeSeedConnections, runSeed } from "./seed/run"; + +async function main() { + const e = env(); + const hello = await getDb().admin().command({ hello: 1 }); + if (!hello.setName) { + throw new Error( + "MongoDB replica set olarak çalışmıyor; transaction'lı yazmalar başarısız olur. " + + "docs/DEPLOY.md › 'MongoDB replica set' adımlarını uygulayın.", + ); + } + if (!hello.isWritablePrimary) throw new Error("MongoDB düğümü PRIMARY değil (yazılamaz)."); + console.log(`• MongoDB ${hello.setName} PRIMARY`); + + if (e.NODE_ENV === "production" && e.SEED_ADMIN_EMAIL && e.SEED_ADMIN_PASSWORD) { + // Production'da seed yalnızca migration + admin yapar (örnek veri oluşturulmaz). + await runSeed(); + } else { + await runMigrations(); + if (e.NODE_ENV === "production") + console.log("• SEED_ADMIN_* tanımlı değil; admin kontrolü atlandı."); + } +} + +main() + .catch((err: unknown) => { + console.error(`✗ Başlatma hatası: ${err instanceof Error ? err.message : err}`); + process.exitCode = 1; + }) + .finally(async () => { + await closeSeedConnections(); + await mongoose.disconnect(); + await getMongoClient().close(); + }); diff --git a/src/app/api/health/route.ts b/src/app/api/health/route.ts index 47b26c1..9e826a2 100644 --- a/src/app/api/health/route.ts +++ b/src/app/api/health/route.ts @@ -1,5 +1,5 @@ import { connection } from "next/server"; -import { pingDb } from "@/lib/db/mongo"; +import { getDb, pingDb } from "@/lib/db/mongo"; import { APP_COMMIT, APP_VERSION } from "@/lib/version"; /** @@ -10,10 +10,21 @@ export async function GET() { // Cache Components açıkken GET handler'ı build'de prerender edilmesin. await connection(); const dbOk = await pingDb(); + // Transaction'lar için replica set şart; adı yoksa (standalone) null. + let replicaSet: string | null = null; + if (dbOk) { + try { + const hello = await getDb().admin().command({ hello: 1 }, { timeoutMS: 2_000 }); + replicaSet = typeof hello.setName === "string" ? hello.setName : null; + } catch { + replicaSet = null; + } + } return Response.json( { status: dbOk ? "ok" : "degraded", db: dbOk ? "ok" : "unreachable", + replicaSet, version: APP_VERSION, commit: APP_COMMIT, time: new Date().toISOString(),