axe/cmd/seed/main.go

344 lines
12 KiB
Go
Raw Permalink Normal View History

2026-10-18 19:07:25 +00:00
// Команда 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).")
}