axe/cmd/seed/main.go
2026-10-18 22:07:25 +03:00

344 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Команда 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).")
}