// Команда seed — демо-данные, чтобы руками проверять API через Swagger. package main // Разовая команда для локальной или демо-базы: // // go run ./cmd/seed --yes # или: make seed // // Создаёт клиента, мастеров, проект с объектами и заявки в разных // статусах — то, что обычно приходится собирать пятью запросами руками. // Повторный запуск ничего не дублирует: без --force команда просто // сообщает, что данные уже есть. // // В прод не запускать: пароли демо-аккаунтов фиксированные и печатаются // в stdout. Флаг --yes добавляет и админа — без него команду можно гонять // даже на базе, где уже есть боевые пользователи. import ( "context" "flag" "fmt" "os" "github.com/google/uuid" "github.com/joho/godotenv" "building-mgmt/internal/auth" "building-mgmt/internal/db" "building-mgmt/internal/logging" "building-mgmt/internal/models" "building-mgmt/internal/repository" ) // Демо-аккаунты. Один пароль на всех — его не надо помнить, он печатается. const ( demoPassword = "demo-password-123" demoClientEmail = "demo@example.com" demoWorkerEmail = "demo-worker@example.com" demoWorker2Email = "demo-worker2@example.com" demoAdminEmail = "demo-admin@example.com" ) func main() { os.Exit(run()) } // run возвращает код выхода, чтобы defer (закрытие пула, откат // транзакции) гарантированно отработал до завершения процесса. func run() int { force := flag.Bool("force", false, "пересоздать демо-данные: старые удаляются") withAdmin := flag.Bool("yes", false, "создать и демо-админа (аккаунт с известным паролем)") flag.Parse() _ = godotenv.Load() logger := logging.Setup() // Прод закрыт: демо-аккаунты с фиксированным паролем — дыра в // безопасности, а не рутина. Бинарник в образе остаётся (наличие // инструмента не страшно), а вот запуститься на бою он не сможет. if os.Getenv("APP_ENV") == "production" { logger.Error("seed refuses to run with APP_ENV=production") return 1 } dsn := os.Getenv("DATABASE_URL") if dsn == "" { logger.Error("DATABASE_URL is not set") return 1 } ctx := context.Background() pool, err := db.Connect(ctx, dsn) if err != nil { logger.Error("db connect failed", "err", err) return 1 } defer pool.Close() // Схема должна существовать — та же команда, что и при старте API. // Заодно команда честно падает, если БД недостижима или миграция сломана. if err := db.Migrate(ctx, pool); err != nil { logger.Error("migrations failed", "err", err) return 1 } hasClient, hasAdmin, err := demoExists(ctx, pool) if err != nil { logger.Error("demo lookup failed", "err", err) return 1 } if hasClient && !*force { logger.Info("demo data already present, nothing to do (use --force to recreate)") printAccounts(hasAdmin) return 0 } tx, err := pool.Begin(ctx) if err != nil { logger.Error("begin tx failed", "err", err) return 1 } defer func() { _ = tx.Rollback(ctx) }() if *force { if err := dropDemo(ctx, tx); err != nil { logger.Error("drop demo data failed", "err", err) return 1 } } adminID, err := seed(ctx, tx, *withAdmin) if err != nil { logger.Error("seed failed", "err", err) return 1 } if err := tx.Commit(ctx); err != nil { logger.Error("commit failed", "err", err) return 1 } if *withAdmin && adminID != "" { logger.Warn("demo admin created with a known password — never run seed against production") } logger.Info("demo data ready") printAccounts(*withAdmin) return 0 } // demoExists — есть ли уже демо-клиент и дем-админ (аккаунт админа // добавляется только по --yes, поэтому проверяется отдельно). func demoExists(ctx context.Context, q repository.Querier) (client, admin bool, err error) { err = q.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM profiles WHERE email = $1), EXISTS (SELECT 1 FROM profiles WHERE email = $2)`, demoClientEmail, demoAdminEmail, ).Scan(&client, &admin) return client, admin, err } // dropDemo — удалить прошлые демо-данные в порядке, диктуемом FK: // сначала заявки, потом структура проекта, профили в конце. func dropDemo(ctx context.Context, q repository.Querier) error { emails := []string{demoClientEmail, demoWorkerEmail, demoWorker2Email, demoAdminEmail} _, err := q.Exec(ctx, ` DELETE FROM tickets WHERE client_id IN (SELECT id FROM profiles WHERE email = ANY($1))`, emails) if err != nil { return fmt.Errorf("tickets: %w", err) } _, err = q.Exec(ctx, ` DELETE FROM project_workers WHERE project_id IN (SELECT id FROM projects WHERE client_id IN (SELECT id FROM profiles WHERE email = ANY($1)))`, emails) if err != nil { return fmt.Errorf("workers: %w", err) } _, err = q.Exec(ctx, ` DELETE FROM project_objects WHERE project_id IN (SELECT id FROM projects WHERE client_id IN (SELECT id FROM profiles WHERE email = ANY($1)))`, emails) if err != nil { return fmt.Errorf("objects: %w", err) } _, err = q.Exec(ctx, ` DELETE FROM projects WHERE client_id IN (SELECT id FROM profiles WHERE email = ANY($1))`, emails) if err != nil { return fmt.Errorf("projects: %w", err) } _, err = q.Exec(ctx, `DELETE FROM profiles WHERE email = ANY($1)`, emails) if err != nil { return fmt.Errorf("profiles: %w", err) } return nil } // seed — вся демо-выборка одной транзакцией: либо данные появляются // целиком, либо ничего. Возвращает id админа (пусто, если не создавали). func seed(ctx context.Context, q repository.Querier, withAdmin bool) (string, error) { hash, err := auth.HashPassword(demoPassword) if err != nil { return "", fmt.Errorf("hash password: %w", err) } profiles := repository.NewProfileRepo(q) projects := repository.NewProjectRepo(q) tickets := repository.NewTicketRepo(q) client := newProfile(demoClientEmail, "Демо клиент", "client", hash) worker := newProfile(demoWorkerEmail, "Демо мастер", "worker", hash) worker2 := newProfile(demoWorker2Email, "Демо мастер №2", "worker", hash) var created []*models.Profile for _, p := range []*models.Profile{client, worker, worker2} { if err := profiles.Create(ctx, p); err != nil { return "", fmt.Errorf("create %s: %w", p.Email, err) } created = append(created, p) } adminID := "" if withAdmin { admin := newProfile(demoAdminEmail, "Демо админ", "admin", hash) if err := profiles.Create(ctx, admin); err != nil { return "", fmt.Errorf("create %s: %w", admin.Email, err) } adminID = admin.ID created = append(created, admin) } // Демо-аккаунты подтверждены сразу (0010): показ должен логиниться // без лишних действий — Create вставляет без актора и потому // оставляет confirmed_at NULL. Трогаем только созданные здесь профили. for _, p := range created { if _, err := q.Exec(ctx, `UPDATE profiles SET confirmed_at = now() WHERE id = $1`, p.ID); err != nil { return "", fmt.Errorf("confirm %s: %w", p.Email, err) } } // Проект с двумя объектами и мастером — ядро новой модели. project := &models.Project{ClientID: client.ID, Name: "Демо-проект", IsActive: true} if err := projects.CreateProject(ctx, project); err != nil { return "", fmt.Errorf("create project: %w", err) } o1 := &models.Object{ProjectID: project.ID, Name: "Демо-дом", Address: "ул. Демо, 1", IsActive: true} o2 := &models.Object{ProjectID: project.ID, Name: "Корпус 2", Address: "ул. Демо, 2", IsActive: true} for _, o := range []*models.Object{o1, o2} { if err := projects.CreateObject(ctx, o); err != nil { return "", fmt.Errorf("create object: %w", err) } } // Мастер входит по приглашению (0005): клиент зовёт, мастер принимает — // только после accept ему можно назначать заявки. if _, err := projects.InviteWorker(ctx, project.ID, worker.ID, client.ID); err != nil { return "", fmt.Errorf("invite worker: %w", err) } if err := projects.AcceptInvitation(ctx, project.ID, worker.ID); err != nil { return "", fmt.Errorf("accept invitation: %w", err) } // Четыре заявки, закрывающие все ветки статусной машины. if err := makeTicket(ctx, tickets, client.ID, project.ID, o1.ID, o1.Address, "electric", "Не горит свет на втором этаже", "high", "assigned", worker.ID); err != nil { return "", err } if err := makeTicket(ctx, tickets, client.ID, project.ID, o1.ID, o1.Address, "plumbing", "Течёт кран в санузле", "normal", "done", worker.ID); err != nil { return "", err } if err := makeTicket(ctx, tickets, client.ID, project.ID, o2.ID, o2.Address, "elevator", "Лифт останавливается между этажами", "high", "new", ""); err != nil { return "", err } if err := makeTicket(ctx, tickets, client.ID, project.ID, o2.ID, o2.Address, "climate", "Кондиционер не включается", "low", "closed", worker.ID); err != nil { return "", err } return adminID, nil } // makeTicket создаёт заявку и доводит её до нужного статуса — // теми же методами репозитория, что и боевые хендлеры. func makeTicket( ctx context.Context, tickets *repository.TicketRepo, clientID string, projectID, objectID int64, address, category, description, priority, status, workerID string, ) error { t := &models.Ticket{ ClientID: clientID, ProjectID: projectID, ObjectID: objectID, Address: address, Category: category, Description: description, Priority: priority, } if err := tickets.Create(ctx, t); err != nil { return fmt.Errorf("create ticket: %w", err) } if status == "new" { return nil } if err := tickets.Assign(ctx, t.ID, workerID, clientID); err != nil { return fmt.Errorf("assign ticket: %w", err) } if status == "assigned" { return nil } if err := tickets.Start(ctx, t.ID, workerID); err != nil { return fmt.Errorf("start ticket: %w", err) } if status == "in_progress" { return nil } if err := tickets.Done(ctx, t.ID, workerID); err != nil { return fmt.Errorf("done ticket: %w", err) } if status == "done" { return nil } // Последняя стадия — closed через подтверждение клиента. return tickets.Confirm(ctx, t.ID, clientID) } func newProfile(email, name, role, hash string) *models.Profile { return &models.Profile{ ID: uuid.NewString(), Email: email, PasswordHash: hash, Name: name, Role: role, IsActive: true, } } // printAccounts — итог для человека: что за аккаунты появились // и под каким паролем логиниться в Swagger. func printAccounts(withAdmin bool) { fmt.Println() fmt.Println("Демо-данные. Пароль один у всех: " + demoPassword) fmt.Println() fmt.Println(" роль email") fmt.Println(" клиент " + demoClientEmail) fmt.Println(" мастер " + demoWorkerEmail) fmt.Println(" мастер " + demoWorker2Email) if withAdmin { fmt.Println(" админ " + demoAdminEmail) } else { fmt.Println(" админ — не создан (запустите с --yes, если нужен)") } fmt.Println() fmt.Println("Проект «Демо-проект»: 2 объекта, 1 мастер, 4 заявки (new, assigned, done, closed).") }