diff --git a/README.md b/README.md index c9ea8a1..3023e11 100644 --- a/README.md +++ b/README.md @@ -99,7 +99,7 @@ docker compose up -d --build Docker Compose intentionally fails fast when required Supabase environment values are missing. -Coolify and Dokploy users should use `docker-compose.full.yml` for the no-external-service setup, or `docker-compose.yml` when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist. +Coolify users should use `docker-compose.full.yml` for the no-external-service setup. Dokploy users should use `docker-compose.dokploy.yml`, which avoids fixed container names and host port bindings. Use `docker-compose.yml` only when connecting to an existing Supabase backend. See `docs/deployment/self-hosting.md` for the deployment checklist. ### Operations diff --git a/docker-compose.dokploy.yml b/docker-compose.dokploy.yml index 55b788e..7ed25f9 100644 --- a/docker-compose.dokploy.yml +++ b/docker-compose.dokploy.yml @@ -115,7 +115,9 @@ services: neta-storage: condition: service_started environment: + NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1" DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in Dokploy env}@neta-db:5432/postgres + NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: - ./supabase:/app/supabase:ro - ./scripts:/app/scripts:ro diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 9caaf70..2b3e340 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -121,7 +121,9 @@ services: neta-storage: condition: service_started environment: + NETA_MIGRATION_RUNNER_VERSION: "2026-06-14.1" DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@neta-db:5432/postgres + NETA_POSTGREST_RELOAD_WAIT_SECONDS: ${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2} volumes: - ./supabase:/app/supabase:ro - ./scripts:/app/scripts:ro diff --git a/docs/database/query-order.md b/docs/database/query-order.md index ad25672..62ee3ed 100644 --- a/docs/database/query-order.md +++ b/docs/database/query-order.md @@ -37,4 +37,4 @@ Use the migration helper from the repository root: DATABASE_URL='postgresql://postgres:password@host:5432/postgres' sh ./scripts/apply-migrations.sh ``` -The helper applies `0001` through `0009` in the order listed above. It uses local `psql` when available, otherwise it runs `psql` through Docker. +The helper applies missing queries from `0001` through `0009` in the order listed above and records completed migrations in `neta_internal.schema_migrations`. It uses local `psql` when available, otherwise it runs `psql` through Docker. After migrations, it sends `NOTIFY pgrst, 'reload schema'` so PostgREST can see new RPC functions without a manual restart. diff --git a/docs/deployment/self-hosting.md b/docs/deployment/self-hosting.md index 55beabc..2518757 100644 --- a/docs/deployment/self-hosting.md +++ b/docs/deployment/self-hosting.md @@ -20,7 +20,7 @@ Services: - `neta-rest`: PostgREST - `neta-storage`: Supabase Storage with local disk backend - `neta-supabase-proxy`: single public API entrypoint -- `neta-migrations`: one-shot Neta database migration runner +- `neta-migrations`: Neta database migration runner Generate production secrets: @@ -92,7 +92,7 @@ Set the generated full-stack env values in Coolify's environment variables. Rout ## Dokploy -Create a Compose app from this repository. Use `docker-compose.full.yml` for full-stack deployments and paste the generated env values into the environment panel. +Create a Compose app from this repository. Use `docker-compose.dokploy.yml` for full-stack deployments and paste the generated env values into the environment panel. This compose file avoids fixed container names and host port bindings so Dokploy can route services itself. Route the Neta domain to `neta-web` port `3000`. Route the bundled Supabase API domain, if used, to `neta-supabase-proxy` port `8000`. @@ -127,3 +127,5 @@ NETA_RESTORE_FORCE=1 sh ./scripts/selfhost-restore.sh ./backups/20260101T120000Z ## First Admin Open `/register` after the stack starts. The first registered user becomes the admin, and public registration is locked after that. + +Full-stack deployments apply the first-admin registration guard automatically through `neta-migrations`; the installer should not run SQL manually. diff --git a/lib/auth/first-admin-setup.ts b/lib/auth/first-admin-setup.ts index dfb422c..240a5fe 100644 --- a/lib/auth/first-admin-setup.ts +++ b/lib/auth/first-admin-setup.ts @@ -5,6 +5,54 @@ type FirstAdminSetupState = { errorMessage?: string; }; +function isMissingSetupFunctionError(error: { + code?: string; + message?: string; +}) { + return ( + error.code === "PGRST202" || + error.message?.includes("is_first_admin_setup_available") + ); +} + +async function getSetupStateFromProfiles(): Promise { + const supabaseUrl = + process.env.SUPABASE_INTERNAL_URL || process.env.NEXT_PUBLIC_SUPABASE_URL; + const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; + + if (!supabaseUrl || !serviceRoleKey) { + return null; + } + + const endpoint = new URL("/rest/v1/profiles", supabaseUrl); + endpoint.searchParams.set("select", "id"); + endpoint.searchParams.set("limit", "1"); + + try { + const response = await fetch(endpoint, { + cache: "no-store", + headers: { + apikey: serviceRoleKey, + authorization: `Bearer ${serviceRoleKey}`, + }, + }); + + if (!response.ok) { + console.error("First admin setup fallback check failed", { + status: response.status, + body: await response.text(), + }); + return null; + } + + const rows = (await response.json()) as Array<{ id: string }>; + return { available: rows.length === 0 }; + } catch (error) { + console.error("First admin setup fallback request failed", error); + return null; + } +} + export async function getFirstAdminSetupState(): Promise { const supabase = await createClient(); const { data, error } = await supabase.rpc("is_first_admin_setup_available"); @@ -17,15 +65,22 @@ export async function getFirstAdminSetupState(): Promise { hint: error.hint, }); - const missingMigration = - error.code === "PGRST202" || - error.message?.includes("is_first_admin_setup_available"); + const fallbackState = await getSetupStateFromProfiles(); + + if (fallbackState) { + if (isMissingSetupFunctionError(error)) { + console.warn( + "First admin setup RPC is not available through PostgREST yet; using service-role profile fallback.", + ); + } + + return fallbackState; + } return { available: false, - errorMessage: missingMigration - ? "İlk kurulum kontrolü yapılamadı. 0009 kayıt kilidi migration dosyasını uygulamanın bağlı olduğu Supabase projesinde çalıştırdığından emin ol." - : "İlk kurulum kontrolü yapılamadı. Supabase bağlantısını ve sunucu loglarını kontrol et.", + errorMessage: + "İlk kurulum kontrolü yapılamadı. Veritabanı hazırlık servisi henüz tamamlanmamış olabilir; birkaç saniye sonra tekrar deneyin.", }; } diff --git a/scripts/apply-migrations.sh b/scripts/apply-migrations.sh index 3d9cb3a..bf51f0d 100644 --- a/scripts/apply-migrations.sh +++ b/scripts/apply-migrations.sh @@ -3,6 +3,7 @@ set -eu ROOT_DIR="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +PSQL_IMAGE="${NETA_PSQL_IMAGE:-postgres:16-alpine}" if [ -z "${DATABASE_URL:-}" ]; then echo "DATABASE_URL is required." >&2 @@ -11,6 +12,93 @@ if [ -z "${DATABASE_URL:-}" ]; then exit 1 fi +run_sql() { + sql="$1" + + if command -v psql >/dev/null 2>&1; then + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql" + elif command -v docker >/dev/null 2>&1; then + docker run --rm -i "$PSQL_IMAGE" \ + psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -Atc "$sql" + else + echo "Neither psql nor docker is available to run migrations." >&2 + exit 1 + fi +} + +query_scalar() { + run_sql "$1" | tail -n 1 | tr -d '[:space:]' +} + +ensure_migration_table() { + run_sql " + create schema if not exists neta_internal; + create table if not exists neta_internal.schema_migrations ( + version text primary key, + file_path text not null, + applied_at timestamp with time zone default timezone('utc'::text, now()) not null + ); + revoke all on schema neta_internal from public; + revoke all on all tables in schema neta_internal from public; + " >/dev/null +} + +is_migration_recorded() { + migration_id="$1" + result="$(query_scalar "select case when exists (select 1 from neta_internal.schema_migrations where version = '$migration_id') then 'yes' else 'no' end;")" + [ "$result" = "yes" ] +} + +record_migration() { + migration_id="$1" + file_path="$2" + + run_sql " + insert into neta_internal.schema_migrations (version, file_path) + values ('$migration_id', '$file_path') + on conflict (version) do update set + file_path = excluded.file_path, + applied_at = timezone('utc'::text, now()); + " >/dev/null +} + +existing_objects_cover_migration() { + migration_id="$1" + + case "$migration_id" in + 0001_schema) + query_scalar "select case when to_regclass('public.profiles') is not null and to_regclass('public.tasks') is not null then 'yes' else 'no' end;" + ;; + 0002_freelancer_os_core) + query_scalar "select case when to_regclass('public.clients') is not null and to_regclass('public.projects') is not null and to_regclass('public.calendar_events') is not null and to_regclass('public.finance_transactions') is not null and to_regclass('public.daily_logs') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'tasks' and column_name = 'priority') then 'yes' else 'no' end;" + ;; + 0003_project_planning_assets) + query_scalar "select case when to_regclass('public.project_planning_sections') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'cover_image_path') then 'yes' else 'no' end;" + ;; + 0004_business_os_tables) + query_scalar "select case when to_regclass('public.proposals') is not null and to_regclass('public.contracts') is not null and to_regclass('public.invoices') is not null and to_regclass('public.subscriptions') is not null then 'yes' else 'no' end;" + ;; + 0005_advanced_crm) + query_scalar "select case when to_regclass('public.client_activities') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'pipeline_stage') then 'yes' else 'no' end;" + ;; + 0006_pgvector_embeddings) + query_scalar "select case when to_regclass('public.document_embeddings') is not null and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'match_documents') then 'yes' else 'no' end;" + ;; + 0007_client_portal) + query_scalar "select case when to_regclass('public.project_revisions') is not null and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'profiles' and column_name = 'role') and exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'clients' and column_name = 'client_auth_id') then 'yes' else 'no' end;" + ;; + 0008_project_progress_quota) + query_scalar "select case when exists (select 1 from information_schema.columns where table_schema = 'public' and table_name = 'projects' and column_name = 'progress_type') and exists (select 1 from pg_proc p join pg_namespace n on n.oid = p.pronamespace where n.nspname = 'public' and p.proname = 'update_project_progress_on_task_change') then 'yes' else 'no' end;" + ;; + 0009_first_admin_registration_lock) + echo "no" + ;; + *) + echo "no" + ;; + esac +} + run_sql_file() { file_path="$1" absolute_path="$ROOT_DIR/$file_path" @@ -25,7 +113,7 @@ run_sql_file() { if command -v psql >/dev/null 2>&1; then psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f "$absolute_path" elif command -v docker >/dev/null 2>&1; then - docker run --rm -i postgres:16-alpine \ + docker run --rm -i "$PSQL_IMAGE" \ psql "$DATABASE_URL" -v ON_ERROR_STOP=1 -f - < "$absolute_path" else echo "Neither psql nor docker is available to run migrations." >&2 @@ -33,19 +121,48 @@ run_sql_file() { fi } -while IFS= read -r sql_file; do - [ -n "$sql_file" ] || continue - run_sql_file "$sql_file" +run_migration() { + migration_id="$1" + file_path="$2" + + if is_migration_recorded "$migration_id"; then + echo "Skipping $file_path; migration $migration_id is already recorded." + return + fi + + if [ "$(existing_objects_cover_migration "$migration_id")" = "yes" ]; then + echo "Detected existing database objects for $migration_id; recording without replay." + record_migration "$migration_id" "$file_path" + return + fi + + run_sql_file "$file_path" + record_migration "$migration_id" "$file_path" +} + +reload_postgrest_schema_cache() { + echo "Reloading PostgREST schema cache." + run_sql "notify pgrst, 'reload schema';" >/dev/null + sleep "${NETA_POSTGREST_RELOAD_WAIT_SECONDS:-2}" +} + +ensure_migration_table + +while IFS='|' read -r migration_id sql_file; do + [ -n "$migration_id" ] || continue + run_migration "$migration_id" "$sql_file" done <<'SQL_FILES' -supabase/schema.sql -supabase/migrations/0002_add_freelancer_os_core_tables.sql -supabase/migrations/0003_add_project_planning_assets.sql -supabase/migrations/0004_add_business_os_tables.sql -supabase/migrations/0005_add_advanced_crm_tables.sql -supabase/migrations/0006_add_pgvector_and_embeddings.sql -supabase/migrations/0007_add_client_portal_tables.sql -supabase/migrations/0008_add_project_progress_and_quota.sql -supabase/migrations/0009_lock_registration_after_first_admin.sql +0001_schema|supabase/schema.sql +0002_freelancer_os_core|supabase/migrations/0002_add_freelancer_os_core_tables.sql +0003_project_planning_assets|supabase/migrations/0003_add_project_planning_assets.sql +0004_business_os_tables|supabase/migrations/0004_add_business_os_tables.sql +0005_advanced_crm|supabase/migrations/0005_add_advanced_crm_tables.sql +0006_pgvector_embeddings|supabase/migrations/0006_add_pgvector_and_embeddings.sql +0007_client_portal|supabase/migrations/0007_add_client_portal_tables.sql +0008_project_progress_quota|supabase/migrations/0008_add_project_progress_and_quota.sql +0009_first_admin_registration_lock|supabase/migrations/0009_lock_registration_after_first_admin.sql SQL_FILES +reload_postgrest_schema_cache + echo "All Neta migrations were applied." diff --git a/supabase/migrations/0009_lock_registration_after_first_admin.sql b/supabase/migrations/0009_lock_registration_after_first_admin.sql index 1bf2e1c..cdcb74f 100644 --- a/supabase/migrations/0009_lock_registration_after_first_admin.sql +++ b/supabase/migrations/0009_lock_registration_after_first_admin.sql @@ -37,3 +37,11 @@ begin return new; end; $$; + +drop trigger if exists on_auth_user_created on auth.users; + +create trigger on_auth_user_created + after insert on auth.users + for each row execute procedure public.handle_new_user(); + +notify pgrst, 'reload schema'; diff --git a/supabase/schema.sql b/supabase/schema.sql index c29886d..0deeb09 100644 --- a/supabase/schema.sql +++ b/supabase/schema.sql @@ -175,17 +175,44 @@ drop policy if exists "Users can update their own profile." on public.profiles; create policy "Users can update their own profile." on public.profiles for update using (auth.uid() = id); +-- First admin setup guard for self-hosted installations +create or replace function public.is_first_admin_setup_available() +returns boolean +language sql +security definer +set search_path = public +as $$ + select not exists ( + select 1 + from public.profiles + limit 1 + ); +$$; + +revoke all on function public.is_first_admin_setup_available() from public; +grant execute on function public.is_first_admin_setup_available() to anon; +grant execute on function public.is_first_admin_setup_available() to authenticated; + -- Function to handle new user signup create or replace function public.handle_new_user() -returns trigger as $$ +returns trigger +language plpgsql +security definer +set search_path = public +as $$ begin + if exists (select 1 from public.profiles limit 1) + and coalesce(new.raw_app_meta_data->>'internal_created', 'false') <> 'true' then + raise exception 'Registration is closed. The first admin account already exists.'; + end if; + insert into public.profiles (id, first_name, last_name, avatar_url) values (new.id, '', '', '') on conflict (id) do nothing; return new; end; -$$ language plpgsql security definer; +$$; -- Trigger to automatically create profile on signup drop trigger if exists on_auth_user_created on auth.users;