fix(seed): count soft-deleted rows + make platform seeding non-fatal
CI/CD / CI · API (dotnet build + test) (push) Successful in 45s
CI/CD / CI · Admin API (dotnet build) (push) Successful in 33s
CI/CD / CI · Dashboard (tsc) (push) Successful in 1m4s
CI/CD / CI · Admin Web (tsc) (push) Successful in 34s
CI/CD / CI · Website (tsc) (push) Successful in 43s
CI/CD / CI · Koja (tsc) (push) Successful in 48s
CI/CD / Deploy · all services (push) Successful in 1m20s

Root cause of the crash-loop: a soft-deleted Free plan still occupies its Tier in the unique index, but the existing-row check queried THROUGH the soft-delete global filter and missed it, so the seeder re-inserted Free and violated IX_PlatformPlanDefinitions_Tier on boot. Fixes: (1) IgnoreQueryFilters() on the plan/feature existing-checks so soft-deleted tiers/keys are counted; (2) wrap plan/feature/location seeding in try/catch so any seeding failure logs and startup continues — non-essential seeding must never crash-loop the API.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-02 02:26:11 +03:30
parent 3b8dcf3af6
commit 09bba5f8cd
@@ -29,15 +29,24 @@ public static class PlatformDataSeeder
// fresh deploy. Idempotent. Phone is overridable via "Seed:SystemAdminPhone".
await EnsureOwnerAdminAsync(db, config, logger);
// Production-safe: give cafés without a map pin an approximate location
// from their city, so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Best-effort, NON-FATAL seeding. These steps populate convenience data
// (map pins, plan/feature catalog) and must never crash-loop the API on
// boot — a failure is logged and startup continues so the service serves.
try
{
// Give cafés without a map pin an approximate location from their
// city so the public map lights up. Idempotent (fills nulls).
await BackfillCafeLocationsAsync(db, logger);
// Subscription plans + feature flags are platform config the admin panel
// reads in every environment. Idempotent: adds any rows that are missing
// (so prod, which only had the Free plan, gets Pro/Business/Enterprise).
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
// Subscription plans + feature flags the admin panel reads in every
// environment. Idempotent: adds any tiers/keys that are missing.
await SeedPlansAsync(db, logger);
await SeedFeaturesAsync(db, logger);
}
catch (Exception ex)
{
logger.LogError(ex, "Non-fatal platform seeding step failed; continuing startup");
}
if (!env.IsDevelopment())
{
@@ -425,7 +434,12 @@ public static class PlatformDataSeeder
// Tier (not Id) carries the unique constraint, so dedupe on Tier — an
// existing Free plan may have a different Id, and inserting another
// Free-tier row would violate IX_PlatformPlanDefinitions_Tier.
var existingTiers = (await db.PlatformPlanDefinitions.Select(p => p.Tier).ToListAsync())
// IgnoreQueryFilters: a SOFT-DELETED plan still occupies its Tier in the
// unique index, so it must be counted or the insert collides on boot.
var existingTiers = (await db.PlatformPlanDefinitions
.IgnoreQueryFilters()
.Select(p => p.Tier)
.ToListAsync())
.ToHashSet();
var missing = plans.Where(p => !existingTiers.Contains(p.Tier)).ToArray();
if (missing.Length == 0) return;
@@ -463,7 +477,11 @@ public static class PlatformDataSeeder
};
// Key carries the unique constraint, so dedupe on Key (not Id).
var existingKeys = (await db.PlatformFeatures.Select(f => f.Key).ToListAsync())
// IgnoreQueryFilters so a soft-deleted feature's Key is still counted.
var existingKeys = (await db.PlatformFeatures
.IgnoreQueryFilters()
.Select(f => f.Key)
.ToListAsync())
.ToHashSet(StringComparer.Ordinal);
var missing = features.Where(f => !existingKeys.Contains(f.Key)).ToArray();
if (missing.Length == 0) return;