c69c028028
- 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>
188 lines
5.5 KiB
Go
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)
|
|
}
|
|
}
|