649 lines
18 KiB
Go
649 lines
18 KiB
Go
package server
|
|
|
|
import (
|
|
"embed"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io/fs"
|
|
"log/slog"
|
|
"net/http"
|
|
"os"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/writekitapp/writekit/internal/auth"
|
|
)
|
|
|
|
//go:embed templates/*.html
|
|
var templatesFS embed.FS
|
|
|
|
//go:embed static/*
|
|
var staticFS embed.FS
|
|
|
|
var subdomainRegex = regexp.MustCompile(`^[a-z0-9][a-z0-9-]{0,61}[a-z0-9]$|^[a-z0-9]$`)
|
|
|
|
func (s *Server) platformHome(w http.ResponseWriter, r *http.Request) {
|
|
content, err := templatesFS.ReadFile("templates/index.html")
|
|
if err != nil {
|
|
slog.Error("read index template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
html := string(content)
|
|
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
|
|
html = strings.ReplaceAll(html, "{{VERSION}}", "v1.0.0")
|
|
html = strings.ReplaceAll(html, "{{COMMIT}}", "dev")
|
|
html = strings.ReplaceAll(html, "{{DEMO_MINUTES}}", "15")
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
func (s *Server) notFound(w http.ResponseWriter, r *http.Request) {
|
|
content, err := templatesFS.ReadFile("templates/404.html")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.WriteHeader(http.StatusNotFound)
|
|
w.Write(content)
|
|
}
|
|
|
|
func (s *Server) platformLogin(w http.ResponseWriter, r *http.Request) {
|
|
http.Redirect(w, r, "/signup", http.StatusFound)
|
|
}
|
|
|
|
func (s *Server) platformSignup(w http.ResponseWriter, r *http.Request) {
|
|
content, err := templatesFS.ReadFile("templates/signup.html")
|
|
if err != nil {
|
|
slog.Error("read signup template", "error", err)
|
|
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
|
|
return
|
|
}
|
|
|
|
html := string(content)
|
|
html = strings.ReplaceAll(html, "{{ACCENT}}", "#10b981")
|
|
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(html))
|
|
}
|
|
|
|
func (s *Server) platformDashboard(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "text/html")
|
|
w.Write([]byte(`<!DOCTYPE html>
|
|
<html>
|
|
<head><title>Dashboard - WriteKit</title></head>
|
|
<body>
|
|
<h1>Dashboard</h1>
|
|
<p>Create your blog or manage your existing one.</p>
|
|
</body>
|
|
</html>`))
|
|
}
|
|
|
|
func (s *Server) serveStaticAssets(w http.ResponseWriter, r *http.Request) {
|
|
sub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
http.NotFound(w, r)
|
|
return
|
|
}
|
|
http.StripPrefix("/assets/", http.FileServer(http.FS(sub))).ServeHTTP(w, r)
|
|
}
|
|
|
|
func (s *Server) checkSubdomain(w http.ResponseWriter, r *http.Request) {
|
|
subdomain := strings.ToLower(r.URL.Query().Get("subdomain"))
|
|
if subdomain == "" {
|
|
jsonError(w, http.StatusBadRequest, "subdomain required")
|
|
return
|
|
}
|
|
|
|
if !subdomainRegex.MatchString(subdomain) {
|
|
jsonResponse(w, http.StatusOK, map[string]any{
|
|
"available": false,
|
|
"reason": "invalid format",
|
|
})
|
|
return
|
|
}
|
|
|
|
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
|
|
if err != nil {
|
|
slog.Error("check subdomain", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, map[string]any{"available": available})
|
|
}
|
|
|
|
func (s *Server) createTenant(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r)
|
|
|
|
var req struct {
|
|
Subdomain string `json:"subdomain"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
subdomain := strings.ToLower(req.Subdomain)
|
|
if !subdomainRegex.MatchString(subdomain) {
|
|
jsonError(w, http.StatusBadRequest, "invalid subdomain format")
|
|
return
|
|
}
|
|
|
|
existing, err := s.database.GetTenantByOwner(r.Context(), userID)
|
|
if err != nil {
|
|
slog.Error("check existing tenant", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
if existing != nil {
|
|
jsonResponse(w, http.StatusConflict, map[string]any{
|
|
"error": "you already have a blog",
|
|
"url": s.buildURL(existing.Subdomain),
|
|
})
|
|
return
|
|
}
|
|
|
|
available, err := s.database.IsSubdomainAvailable(r.Context(), subdomain)
|
|
if err != nil {
|
|
slog.Error("check availability", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
if !available {
|
|
jsonError(w, http.StatusConflict, "subdomain not available")
|
|
return
|
|
}
|
|
|
|
tenant, err := s.database.CreateTenant(r.Context(), userID, subdomain)
|
|
if err != nil {
|
|
slog.Error("create tenant", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "failed to create tenant")
|
|
return
|
|
}
|
|
|
|
if _, err := s.tenantPool.Get(tenant.ID); err != nil {
|
|
slog.Error("init tenant db", "tenant_id", tenant.ID, "error", err)
|
|
}
|
|
|
|
user, _ := s.database.GetUserByID(r.Context(), userID)
|
|
if user != nil {
|
|
if tenantDB, err := s.tenantPool.Get(tenant.ID); err == nil {
|
|
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_name', ?) ON CONFLICT DO NOTHING`, user.Name)
|
|
tenantDB.Exec(`INSERT INTO site_settings (key, value) VALUES ('author_avatar', ?) ON CONFLICT DO NOTHING`, user.AvatarURL)
|
|
}
|
|
}
|
|
|
|
slog.Info("tenant created", "subdomain", subdomain, "user_id", userID, "tenant_id", tenant.ID)
|
|
|
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
|
"subdomain": subdomain,
|
|
"url": s.buildURL(subdomain),
|
|
"tenant_id": tenant.ID,
|
|
})
|
|
}
|
|
|
|
func (s *Server) getTenant(w http.ResponseWriter, r *http.Request) {
|
|
userID := auth.GetUserID(r)
|
|
|
|
tenant, err := s.database.GetTenantByOwner(r.Context(), userID)
|
|
if err != nil {
|
|
slog.Error("get tenant", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "internal error")
|
|
return
|
|
}
|
|
if tenant == nil {
|
|
jsonResponse(w, http.StatusOK, map[string]any{"has_tenant": false})
|
|
return
|
|
}
|
|
|
|
jsonResponse(w, http.StatusOK, map[string]any{
|
|
"has_tenant": true,
|
|
"tenant": map[string]any{
|
|
"id": tenant.ID,
|
|
"subdomain": tenant.Subdomain,
|
|
"url": s.buildURL(tenant.Subdomain),
|
|
"premium": tenant.Premium,
|
|
},
|
|
})
|
|
}
|
|
|
|
func jsonResponse(w http.ResponseWriter, status int, data any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(status)
|
|
json.NewEncoder(w).Encode(data)
|
|
}
|
|
|
|
func jsonError(w http.ResponseWriter, status int, msg string) {
|
|
jsonResponse(w, status, map[string]string{"error": msg})
|
|
}
|
|
|
|
func (s *Server) buildURL(subdomain string) string {
|
|
scheme := "https"
|
|
if env := os.Getenv("ENV"); env != "prod" {
|
|
scheme = "http"
|
|
}
|
|
return fmt.Sprintf("%s://%s.%s", scheme, subdomain, s.domain)
|
|
}
|
|
|
|
func (s *Server) createDemo(w http.ResponseWriter, r *http.Request) {
|
|
origin := r.Header.Get("Origin")
|
|
referer := r.Header.Get("Referer")
|
|
|
|
validOrigins := []string{
|
|
"https://" + s.domain,
|
|
"https://www." + s.domain,
|
|
}
|
|
if env := os.Getenv("ENV"); env != "prod" {
|
|
validOrigins = append(validOrigins,
|
|
"http://"+s.domain,
|
|
"http://www."+s.domain,
|
|
)
|
|
}
|
|
|
|
isValidOrigin := func(o string) bool {
|
|
for _, v := range validOrigins {
|
|
if o == v {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
isValidReferer := func(ref string) bool {
|
|
for _, v := range validOrigins {
|
|
if strings.HasPrefix(ref, v) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
if origin != "" && !isValidOrigin(origin) {
|
|
jsonError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
if origin == "" && referer != "" && !isValidReferer(referer) {
|
|
jsonError(w, http.StatusForbidden, "forbidden")
|
|
return
|
|
}
|
|
|
|
var req struct {
|
|
Name string `json:"name"`
|
|
Color string `json:"color"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
|
jsonError(w, http.StatusBadRequest, "invalid request")
|
|
return
|
|
}
|
|
|
|
if req.Name == "" {
|
|
req.Name = "Demo User"
|
|
}
|
|
if req.Color == "" {
|
|
req.Color = "#10b981"
|
|
}
|
|
|
|
demo, err := s.database.CreateDemo(r.Context())
|
|
if err != nil {
|
|
slog.Error("create demo", "error", err)
|
|
jsonError(w, http.StatusInternalServerError, "failed to create demo")
|
|
return
|
|
}
|
|
|
|
s.tenantPool.MarkAsDemo(demo.ID)
|
|
if _, err := s.tenantPool.Get(demo.ID); err != nil {
|
|
slog.Error("init demo db", "demo_id", demo.ID, "error", err)
|
|
}
|
|
|
|
if err := s.seedDemoContent(demo.ID, req.Name, req.Color); err != nil {
|
|
slog.Error("seed demo content", "demo_id", demo.ID, "error", err)
|
|
}
|
|
|
|
slog.Info("demo created", "subdomain", demo.Subdomain, "demo_id", demo.ID, "expires_at", demo.ExpiresAt)
|
|
|
|
jsonResponse(w, http.StatusCreated, map[string]any{
|
|
"subdomain": demo.Subdomain,
|
|
"url": s.buildURL(demo.Subdomain),
|
|
"expires_at": demo.ExpiresAt.Format("2006-01-02T15:04:05Z"),
|
|
})
|
|
}
|
|
|
|
func (s *Server) seedDemoContent(demoID, authorName, accentColor string) error {
|
|
db, err := s.tenantPool.Get(demoID)
|
|
if err != nil {
|
|
return fmt.Errorf("get tenant pool: %w", err)
|
|
}
|
|
|
|
result, err := db.Exec(`INSERT INTO site_settings (key, value) VALUES
|
|
('site_name', ?),
|
|
('site_description', 'Thoughts on building software, developer tools, and the craft of engineering.'),
|
|
('accent_color', ?),
|
|
('author_name', ?),
|
|
('is_demo', 'true')
|
|
ON CONFLICT DO NOTHING`, authorName+"'s Blog", accentColor, authorName)
|
|
if err != nil {
|
|
return fmt.Errorf("insert settings: %w", err)
|
|
}
|
|
rows, _ := result.RowsAffected()
|
|
slog.Info("seed demo settings", "demo_id", demoID, "rows_affected", rows)
|
|
|
|
_, err = db.Exec(`INSERT INTO posts (id, slug, title, description, content_md, tags, cover_image, is_published, published_at, created_at, modified_at) VALUES
|
|
('demo-post-1', 'shipping-a-side-project',
|
|
'I Finally Shipped My Side Project',
|
|
'After mass years of abandoned repos, here is what finally worked.',
|
|
'I have mass abandoned side projects. We all do. But last month, I actually shipped one.
|
|
|
|
Here''s what I did differently this time.
|
|
|
|
## The Graveyard
|
|
|
|
Before we get to what worked, let me be honest about what didn''t. My GitHub is full of:
|
|
|
|
- [ ] A "better" todo app (mass of features planned, mass built)
|
|
- [ ] A CLI tool I used once
|
|
- [ ] Three different blog engines (ironic, I know)
|
|
- [x] This project — finally shipped
|
|
|
|
The pattern was always the same: mass enthusiasm for a week, mass silence forever.
|
|
|
|
## What Changed
|
|
|
|
This time, I set one rule: **ship in two weeks or kill it**.
|
|
|
|
> "If you''re not embarrassed by the first version of your product, you''ve launched too late." — Reid Hoffman
|
|
|
|
I printed that quote and stuck it on my monitor. Every time I wanted to add "just one more feature," I looked at it.
|
|
|
|
## The Stack
|
|
|
|
I kept it minimal:
|
|
|
|
| Layer | Choice | Why |
|
|
|-------|--------|-----|
|
|
| Frontend | React | I know it well |
|
|
| Backend | Go | Fast, simple deploys |
|
|
| Database | SQLite | No ops overhead |
|
|
| Hosting | Fly.io | $0 to start |
|
|
|
|
~~I originally planned to use Kubernetes~~ — glad I didn''t. SQLite on a single server handles more traffic than I''ll ever get.
|
|
|
|
## The Launch
|
|
|
|
I posted on Twitter, mass likes. mass signups. Then... mass complaints about bugs I hadn''t tested for.
|
|
|
|
But here''s the thing: **those bugs only exist because I shipped**. I fixed them in a day. If I''d waited for "perfect," I''d still be tweaking CSS.
|
|
|
|
---
|
|
|
|
Ship something this week. mass needs to be big. Your mass abandoned project could be mass person''s favorite tool.',
|
|
'["career","shipping"]',
|
|
'https://images.unsplash.com/photo-1517694712202-14dd9538aa97?w=1200&h=630&fit=crop',
|
|
1,
|
|
datetime('now', '-2 days'),
|
|
datetime('now', '-2 days'),
|
|
datetime('now', '-2 days')),
|
|
|
|
('demo-post-2', 'debugging-production-like-a-detective',
|
|
'Debugging Production Like a Detective',
|
|
'A systematic approach to finding bugs when console.log is not enough.',
|
|
'Last week, our API started returning 500 errors. Not always — just enough to be infuriating. Here''s how I tracked it down.
|
|
|
|
## The Symptoms
|
|
|
|
Users reported "random failures." The logs showed:
|
|
|
|
` + "```" + `
|
|
error: connection refused
|
|
error: connection refused
|
|
error: context deadline exceeded
|
|
` + "```" + `
|
|
|
|
Helpful, right?
|
|
|
|
## Step 1: Gather Evidence
|
|
|
|
First, I needed data. I added structured logging:
|
|
|
|
` + "```go" + `
|
|
slog.Error("request failed",
|
|
"endpoint", r.URL.Path,
|
|
"user_id", userID,
|
|
"duration_ms", time.Since(start).Milliseconds(),
|
|
"error", err,
|
|
)
|
|
` + "```" + `
|
|
|
|
Within an hour, a pattern emerged:
|
|
|
|
| Time | Endpoint | Duration | Result |
|
|
|------|----------|----------|--------|
|
|
| 14:01 | /api/posts | 45ms | OK |
|
|
| 14:01 | /api/posts | 52ms | OK |
|
|
| 14:02 | /api/posts | 30,004ms | FAIL |
|
|
| 14:02 | /api/users | 48ms | OK |
|
|
|
|
The failures were always *exactly* 30 seconds — our timeout value.
|
|
|
|
## Step 2: Form a Hypothesis
|
|
|
|
> The 30-second timeout suggested a connection hanging, not failing fast.
|
|
|
|
I suspected connection pool exhaustion. Our pool was set to 10 connections. Under load, requests would wait for a free connection, then timeout.
|
|
|
|
## Step 3: Test the Theory
|
|
|
|
I checked the pool stats:
|
|
|
|
` + "```sql" + `
|
|
SELECT count(*) FROM pg_stat_activity
|
|
WHERE application_name = ''myapp'';
|
|
` + "```" + `
|
|
|
|
Result: **10**. Exactly at the limit.
|
|
|
|
## The Fix
|
|
|
|
` + "```go" + `
|
|
db.SetMaxOpenConns(25) // was: 10
|
|
db.SetMaxIdleConns(10) // was: 2
|
|
db.SetConnMaxLifetime(time.Hour)
|
|
` + "```" + `
|
|
|
|
Three lines. Two days of debugging. Zero more timeouts.
|
|
|
|
---
|
|
|
|
The lesson: **when debugging production, be a detective, not a guesser**. Gather evidence, form hypotheses, test them.',
|
|
'["debugging","golang","databases"]',
|
|
'https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=1200&h=630&fit=crop',
|
|
1,
|
|
datetime('now', '-5 days'),
|
|
datetime('now', '-5 days'),
|
|
datetime('now', '-5 days')),
|
|
|
|
('demo-post-3', 'sqlite-in-production',
|
|
'Yes, You Can Use SQLite in Production',
|
|
'How to scale SQLite further than you think — and when to finally migrate.',
|
|
'Every time I mention SQLite in production, someone says "that doesn''t scale." Let me share some numbers.
|
|
|
|
## The Reality
|
|
|
|
SQLite handles:
|
|
|
|
- **Millions** of reads per second
|
|
- **Thousands** of writes per second (with WAL mode)
|
|
- Databases up to **281 TB** (theoretical limit)
|
|
|
|
For context, that''s more than most apps will ever need.
|
|
|
|
## Configuration That Matters
|
|
|
|
The defaults are conservative. For production, I use:
|
|
|
|
` + "```sql" + `
|
|
PRAGMA journal_mode = WAL;
|
|
PRAGMA synchronous = NORMAL;
|
|
PRAGMA cache_size = -64000; -- 64MB cache
|
|
PRAGMA busy_timeout = 5000;
|
|
` + "```" + `
|
|
|
|
This gives you:
|
|
|
|
| Setting | Default | Production | Impact |
|
|
|---------|---------|------------|--------|
|
|
| journal_mode | DELETE | WAL | Concurrent reads during writes |
|
|
| synchronous | FULL | NORMAL | 10x faster writes, still safe |
|
|
| cache_size | -2000 | -64000 | Fewer disk reads |
|
|
|
|
## When to Migrate
|
|
|
|
SQLite is **not** right when you need:
|
|
|
|
1. Multiple servers writing to the same database
|
|
2. ~~Horizontal scaling~~ (though read replicas now exist via Litestream)
|
|
3. Sub-millisecond writes under heavy contention
|
|
|
|
If you''re running a single server — which is most apps — SQLite is great.
|
|
|
|
> "I''ve never used a database that made me this happy." — every SQLite user, probably
|
|
|
|
## Getting Started
|
|
|
|
Here''s my standard setup:
|
|
|
|
` + "```go" + `
|
|
db, err := sql.Open("sqlite3", "file:app.db?_journal=WAL&_timeout=5000")
|
|
if err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
db.SetMaxOpenConns(1) // SQLite writes are serialized anyway
|
|
|
|
// Run migrations
|
|
if _, err := db.Exec(schema); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
` + "```" + `
|
|
|
|
---
|
|
|
|
Stop overengineering. Start with SQLite. Migrate when you *actually* hit limits — you probably never will.',
|
|
'["sqlite","databases","architecture"]',
|
|
'https://images.unsplash.com/photo-1544383835-bda2bc66a55d?w=1200&h=630&fit=crop',
|
|
1,
|
|
datetime('now', '-8 days'),
|
|
datetime('now', '-8 days'),
|
|
datetime('now', '-8 days')),
|
|
|
|
('demo-post-4', 'my-2024-reading-list',
|
|
'My 2024 Reading List',
|
|
'',
|
|
'Here are the books that shaped my thinking this year.
|
|
|
|
## Technical Books
|
|
|
|
- **Designing Data-Intensive Applications** by Martin Kleppmann
|
|
- **The Pragmatic Programmer** by David Thomas
|
|
- **Staff Engineer** by Will Larson
|
|
|
|
## Non-Technical
|
|
|
|
- **The Mom Test** by Rob Fitzpatrick
|
|
- **Building a Second Brain** by Tiago Forte
|
|
|
|
More thoughts on each coming soon...',
|
|
'["books","learning"]',
|
|
'',
|
|
1,
|
|
datetime('now', '-12 days'),
|
|
datetime('now', '-12 days'),
|
|
datetime('now', '-12 days')),
|
|
|
|
('demo-draft-1', 'understanding-react-server-components',
|
|
'Understanding React Server Components',
|
|
'A deep dive into RSC architecture and when to use them.',
|
|
'React Server Components are confusing. Let me try to explain them.
|
|
|
|
## The Problem RSC Solves
|
|
|
|
Traditional React apps ship a lot of JavaScript to the browser. RSC lets you run components on the server and send only the HTML.
|
|
|
|
## Key Concepts
|
|
|
|
TODO: Add diagrams
|
|
|
|
## When to Use RSC
|
|
|
|
- Data fetching
|
|
- Heavy dependencies
|
|
- SEO-critical pages
|
|
|
|
## When NOT to Use RSC
|
|
|
|
- Interactive components
|
|
- Client state
|
|
- Event handlers
|
|
|
|
Still writing this one...',
|
|
'["react","architecture"]',
|
|
'',
|
|
0,
|
|
NULL,
|
|
datetime('now', '-1 days'),
|
|
datetime('now', '-2 hours')),
|
|
|
|
('demo-draft-2', 'vim-tricks-i-use-daily',
|
|
'Vim Tricks I Use Daily',
|
|
'The shortcuts that actually stuck after 5 years of using Vim.',
|
|
'After mass years of Vim, these are the commands I use daily.
|
|
|
|
## Navigation
|
|
|
|
- Ctrl+d and Ctrl+u for half page down/up
|
|
- zz to center current line
|
|
|
|
## Editing
|
|
|
|
Still collecting my favorites...',
|
|
'["vim","productivity"]',
|
|
'',
|
|
0,
|
|
NULL,
|
|
datetime('now', '-3 days'),
|
|
datetime('now', '-1 days'))
|
|
ON CONFLICT DO NOTHING`)
|
|
if err != nil {
|
|
return fmt.Errorf("insert posts: %w", err)
|
|
}
|
|
slog.Info("seed demo posts", "demo_id", demoID)
|
|
|
|
return nil
|
|
}
|
|
|
|
func (s *Server) ensureDemoSeeded(demoID string) {
|
|
db, err := s.tenantPool.Get(demoID)
|
|
if err != nil {
|
|
slog.Error("ensureDemoSeeded: get pool", "demo_id", demoID, "error", err)
|
|
return
|
|
}
|
|
|
|
var count int
|
|
err = db.QueryRow("SELECT COUNT(*) FROM posts").Scan(&count)
|
|
if err != nil {
|
|
slog.Error("ensureDemoSeeded: count posts", "demo_id", demoID, "error", err)
|
|
return
|
|
}
|
|
|
|
if count > 0 {
|
|
return
|
|
}
|
|
|
|
slog.Info("re-seeding demo content", "demo_id", demoID)
|
|
if err := s.seedDemoContent(demoID, "Demo User", "#10b981"); err != nil {
|
|
slog.Error("ensureDemoSeeded: seed failed", "demo_id", demoID, "error", err)
|
|
}
|
|
}
|