Files
BountyBoard/scripts/seed.go
etalon c69c028028 feat: seed script, forgot-password, hover cards, shortcuts, OpenAPI docs, runbook (phase 12)
- scripts/seed.go: idempotent demo data per §11.11 (make seed)
- forgot/reset password: SMTP-gated, one-shot TTL tokens, uniform responses
  against enumeration, sessions revoked on reset; login page link + pages
- profile hover cards on [data-user-card] elements (§11.13)
- keyboard shortcuts: g b/m/t/h navigation, / focuses search (§10)
- bulk archive endpoint (§11.9)
- hand-written OpenAPI 3.1 covering §6, served at /api/docs + yaml download
- make backup / make restore (mongodump archive via the mongo container)
- README: quick start, demo data, runbook, breaker/job operations, working
  Caddy + nginx reverse-proxy samples (WS block, client_max_body_size),
  documented later-stubs (§11.24)
- smoke.sh now exercises register → logout → login → me → board → pages

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 20:39:38 +02:00

188 lines
5.5 KiB
Go

// Command seed populates a demo environment (§11.11): one demo customer
// (offline ticketing type), 1 admin, 2 consultants, 6 developers, pool
// memberships, and sample conversations. Idempotent: existing users (by
// email) and customers (by name) are reused, not duplicated.
//
// MONGO_SEED_URI=mongodb://127.0.0.1:27017 go run ./scripts
package main
import (
"context"
"fmt"
"log"
"os"
"time"
"go.mongodb.org/mongo-driver/v2/bson"
"bountyboard/internal/auth"
"bountyboard/internal/config"
"bountyboard/internal/domain"
"bountyboard/internal/store"
"bountyboard/internal/ulid"
"go.mongodb.org/mongo-driver/v2/mongo"
"go.mongodb.org/mongo-driver/v2/mongo/options"
)
const demoPassword = "demo-pass-123"
func main() {
_ = config.LoadDotEnv(".env")
uri := os.Getenv("MONGO_SEED_URI")
if uri == "" {
uri = "mongodb://127.0.0.1:27017"
}
dbName := os.Getenv("MONGO_DB")
if dbName == "" {
dbName = "bountyboard"
}
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
defer cancel()
client, err := mongo.Connect(options.Client().ApplyURI(uri).
SetServerSelectionTimeout(5 * time.Second))
if err != nil {
log.Fatalf("connect: %v", err)
}
defer client.Disconnect(context.Background())
st := &store.Store{Client: client, DB: client.Database(dbName)}
if err := store.EnsureIndexes(ctx, st.DB); err != nil {
log.Fatalf("indexes: %v", err)
}
user := func(email, name string, roles domain.Roles) *domain.User {
if u, err := st.UserByEmail(ctx, email); err == nil {
fmt.Printf(" exists: %s\n", email)
return u
}
hash, err := auth.HashPassword(demoPassword)
if err != nil {
log.Fatal(err)
}
u := &domain.User{
ID: ulid.New(), Email: email, Name: name, Roles: roles,
Auth: domain.Auth{Local: &domain.LocalAuth{PasswordHash: hash}},
Settings: domain.UserSettings{Theme: "light",
Notifications: domain.NotificationPrefs{Email: true, InApp: true}},
Bio: "Seeded demo account.",
}
if err := st.CreateUser(ctx, u); err != nil {
log.Fatalf("create %s: %v", email, err)
}
fmt.Printf(" created: %s (password %q)\n", email, demoPassword)
return u
}
fmt.Println("Seeding users…")
user("seed-admin@example.com", "Seed Admin", domain.Roles{Admin: true})
clara := user("clara@example.com", "Clara Consultant", domain.Roles{Consultant: true})
carlos := user("carlos@example.com", "Carlos Consultant", domain.Roles{Consultant: true})
devs := []*domain.User{}
devNames := []string{"Dana", "Devon", "Dimitri", "Daria", "Dylan", "Drew"}
for i, n := range devNames {
devs = append(devs, user(fmt.Sprintf("dev%d@example.com", i+1),
n+" Developer", domain.Roles{Developer: true}))
}
fmt.Println("Seeding pools…")
addPool := func(c, d *domain.User) {
if err := st.AddToPool(ctx, c.ID, d.ID); err != nil && err != store.ErrDuplicate {
log.Fatalf("pool: %v", err)
}
}
for _, d := range devs[:4] {
addPool(clara, d)
}
for _, d := range devs[2:] {
addPool(carlos, d)
}
fmt.Println("Seeding demo customer…")
customers, err := st.ListCustomers(ctx, "", true)
if err != nil {
log.Fatal(err)
}
var demoCustomer *domain.Customer
for i := range customers {
if customers[i].Name == "ACME Demo" {
demoCustomer = &customers[i]
}
}
if demoCustomer == nil {
demoCustomer = &domain.Customer{
Name: "ACME Demo",
Ticketing: domain.Ticketing{
Type: domain.TicketingDemo, ProjectKey: "ACME", PollIntervalSec: 30,
},
ConsultantIDs: []string{clara.ID, carlos.ID},
DefaultBudget: 1500,
}
if err := st.CreateCustomer(ctx, demoCustomer); err != nil {
log.Fatal(err)
}
fmt.Println(" created: ACME Demo (demo ticketing — tickets appear within one poll)")
} else {
fmt.Println(" exists: ACME Demo")
}
fmt.Println("Seeding conversations…")
dm, err := st.FindOrCreateDM(ctx, clara.ID, devs[0].ID)
if err != nil {
log.Fatal(err)
}
n, err := st.DB.Collection("messages").CountDocuments(ctx, bson.M{"conversationId": dm.ID})
if err != nil {
log.Fatal(err)
}
if n == 0 {
msgs := []struct {
from *domain.User
body string
}{
{clara, "<p>Hi Dana — I just added you to my pool. The ACME board fills up shortly.</p>"},
{devs[0], "<p>Great, I will grab the <b>CSV importer</b> once it is published.</p>"},
{clara, "<p>Perfect. Ping me here if any acceptance criteria are unclear.</p>"},
}
for _, m := range msgs {
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: dm.ID, SenderID: m.from.ID, Body: m.body,
}); err != nil {
log.Fatal(err)
}
}
fmt.Println(" created: DM Clara ↔ Dana with 3 messages")
}
groups, _ := st.ListConversations(ctx, clara.ID)
hasGroup := false
for _, g := range groups {
if g.Kind == "group" && g.Title == "ACME standup" {
hasGroup = true
}
}
if !hasGroup {
grp := &store.Conversation{
Kind: "group", Title: "ACME standup",
ParticipantIDs: []string{clara.ID, carlos.ID, devs[0].ID, devs[1].ID, devs[2].ID},
}
if err := st.CreateConversation(ctx, grp); err != nil {
log.Fatal(err)
}
if err := st.InsertMessage(ctx, &store.Message{
ConversationID: grp.ID, SenderID: carlos.ID,
Body: "<p>Welcome to the ACME standup channel. Daily updates here, please.</p>",
}); err != nil {
log.Fatal(err)
}
fmt.Println(" created: group 'ACME standup'")
}
fmt.Println("\nSeed complete.")
fmt.Printf("Demo accounts (password %q):\n", demoPassword)
fmt.Println(" seed-admin@example.com (admin)")
fmt.Println(" clara@example.com, carlos@example.com (consultants)")
for i := range devNames {
fmt.Printf(" dev%d@example.com (developer)\n", i+1)
}
}