344 lines
12 KiB
Go
344 lines
12 KiB
Go
// Команда 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).")
|
||
}
|