diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b7948a02..7bd70252 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,13 @@ Devlane is a monorepo with two deployable apps under `apps/`: 1. **Infra** — `docker compose up -d` (Postgres is published on host port **15432**, not 5432). 2. **API** — from `apps/api`, copy `.env.example` to `.env`, set `DB_PORT=15432` plus your Postgres/Redis (and optional RabbitMQ/MinIO) settings, then `go run ./cmd/api`. Migrations run automatically on startup. 3. **Web** — from `apps/web`, run `npm install` then `npm run dev` (defaults to port 5173). -4. **First run** — complete instance setup in the browser (create the admin account, then a workspace and project). +4. **First run** — either complete instance setup in the browser (create the admin account, then a workspace and project), **or** seed a ready-to-explore instance from `apps/api`: + + ```sh + go run ./cmd/api seed + ``` + + This creates a demo user (`demo@devlane.test` / `Demo1234!`), a **Demo Workspace**, a **Getting Started** project with the standard workflow states, and a handful of sample work items. It's idempotent — re-running it is a no-op once the demo user exists. Sign in with those credentials at the web app. (Local-only demo credentials; never use them in a real deployment.) Before pushing, run the full check from the repo root: diff --git a/README.md b/README.md index e9424a57..2b801033 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Instance administrators can manage workspaces and instance settings from the ins 1. **API** — From the `apps/api` directory, copy `.env.example` to `.env`, set your PostgreSQL and Redis (and optional RabbitMQ/MinIO) settings, run migrations, then start the server (see `apps/api/README.md`). 2. **UI** — From the `apps/web` directory, run `npm install` and `npm run dev`. Point the UI at your local API using the configured base URL. -3. **First run** — Complete instance setup in the browser (create admin account, then create a workspace and project). +3. **First run** — Complete instance setup in the browser (create admin account, then create a workspace and project), or run `go run ./cmd/api seed` from `apps/api` to populate a demo user, workspace, project, and sample work items in one step (see [CONTRIBUTING](CONTRIBUTING.md)). For contribution workflow and code style, see [CONTRIBUTING](CONTRIBUTING.md) if present. diff --git a/apps/api/README.md b/apps/api/README.md index 9874015e..dc501461 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -43,11 +43,18 @@ commonly changed ones for local dev: ```sh go run ./cmd/api # start the API server (auto-runs migrations) +go run ./cmd/api seed # seed a demo workspace/project/issues for local dev +go run ./cmd/api admin grant # grant instance-admin to an existing user go vet ./... # static analysis go test ./... # run all tests go test ./internal/auth -run TestMagicCode # run a single package/test ``` +The `seed` command creates a demo user (`demo@devlane.test` / `Demo1234!`), a +workspace, a project with default workflow states, and sample work items so a +fresh database has something to explore. It's idempotent (a no-op once the demo +user exists). Local-only demo credentials — never use them in a real deployment. + ## Migrations Add paired files under `migrations/`: `NNNNNN_.up.sql` and diff --git a/apps/api/cmd/api/main.go b/apps/api/cmd/api/main.go index 9f95c315..7e0b705a 100644 --- a/apps/api/cmd/api/main.go +++ b/apps/api/cmd/api/main.go @@ -28,6 +28,10 @@ func main() { if len(os.Args) > 1 && os.Args[1] == "admin" { os.Exit(runAdmin(os.Args[2:])) } + // Local development: `api seed` populates a demo workspace/project/issues. + if len(os.Args) > 1 && os.Args[1] == "seed" { + os.Exit(runSeed(os.Args[2:])) + } log := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{ Level: slog.LevelInfo, diff --git a/apps/api/cmd/api/seed.go b/apps/api/cmd/api/seed.go new file mode 100644 index 00000000..751b5e27 --- /dev/null +++ b/apps/api/cmd/api/seed.go @@ -0,0 +1,191 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "os" + + "github.com/Devlaner/devlane/api/internal/auth" + "github.com/Devlaner/devlane/api/internal/config" + "github.com/Devlaner/devlane/api/internal/database" + "github.com/Devlaner/devlane/api/internal/model" + "github.com/Devlaner/devlane/api/internal/service" + "github.com/Devlaner/devlane/api/internal/store" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// Demo credentials for the local-development seed. Not secret; documented in the +// local dev guide. Never use in a real deployment. +const ( + seedEmail = "demo@devlane.test" + seedPassword = "Demo1234!" + seedFirstName = "Demo" + seedLastName = "User" + seedWorkspaceName = "Demo Workspace" + seedWorkspaceSlug = "demo" + seedProjectName = "Getting Started" + seedProjectIdent = "DEMO" +) + +// runSeed handles `api seed`: it populates a local database with a demo user, +// workspace, project, workflow states, and sample work items so a fresh clone +// has something to explore. Idempotent — a second run is a no-op. +func runSeed(args []string) int { + log := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) + if len(args) != 0 { + fmt.Fprintln(os.Stderr, "usage: api seed") + return 2 + } + cfg, err := config.Load() + if err != nil { + fmt.Fprintf(os.Stderr, "config: %v\n", err) + return 1 + } + db, err := database.NewDB(cfg, log) + if err != nil { + fmt.Fprintf(os.Stderr, "database: %v\n", err) + return 1 + } + if sqlDB, err := db.DB(); err == nil { + defer sqlDB.Close() + } + if err := seedDevData(context.Background(), db); err != nil { + fmt.Fprintf(os.Stderr, "seed failed: %v\n", err) + return 1 + } + return 0 +} + +func seedDevData(ctx context.Context, db *gorm.DB) error { + userStore := store.NewUserStore(db) + + // Idempotency: if the demo user already exists, assume the DB is seeded. + // A real lookup error (not just "not found") should surface, not be treated + // as "needs seeding". + existing, err := userStore.GetByEmail(ctx, seedEmail) + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("check demo user: %w", err) + } + if existing != nil { + fmt.Printf("seed: %s already exists — nothing to do\n", seedEmail) + return nil + } + + authSvc := auth.NewService(userStore, store.NewSessionStore(db), store.NewPasswordResetTokenStore(db)) + _, user, err := authSvc.SignUp(ctx, auth.SignUpRequest{ + Email: seedEmail, + Password: seedPassword, + FirstName: seedFirstName, + LastName: seedLastName, + }) + if err != nil { + return fmt.Errorf("create demo user: %w", err) + } + + // Make the demo user an instance admin and mark the instance as set up, so + // the app is immediately usable without the first-run setup wizard. + admins := store.NewInstanceAdminStore(db) + adminCount, err := admins.CountActive(ctx) + if err != nil { + return fmt.Errorf("count instance admins: %w", err) + } + if adminCount == 0 { + if err := admins.Create(ctx, &model.InstanceAdmin{UserID: user.ID, Role: model.RoleOwner, IsVerified: true}); err != nil { + return fmt.Errorf("create instance admin: %w", err) + } + } + settings := store.NewInstanceSettingStore(db) + generalRow, err := settings.Get(ctx, "general") + if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) { + return fmt.Errorf("read instance settings: %w", err) + } + if generalRow == nil { + if err := settings.Upsert(ctx, "general", model.JSONMap{ + "instance_id": "devlocalseed00000000000", + "admin_email": seedEmail, + "instance_name": "Devlane (local)", + "only_admin_can_create_workspace": false, + }); err != nil { + return fmt.Errorf("seed instance settings: %w", err) + } + } + + wsSvc := service.NewWorkspaceService(store.NewWorkspaceStore(db), store.NewWorkspaceInviteStore(db), userStore) + wrk, err := wsSvc.Create(ctx, seedWorkspaceName, seedWorkspaceSlug, "", user.ID) + if err != nil { + return fmt.Errorf("create workspace: %w", err) + } + + projSvc := service.NewProjectService(store.NewProjectStore(db), store.NewProjectInviteStore(db), store.NewWorkspaceStore(db), userStore) + proj, err := projSvc.Create(ctx, wrk.Slug, seedProjectName, seedProjectIdent, user.ID) + if err != nil { + return fmt.Errorf("create project: %w", err) + } + + // Seed a standard set of workflow states, one marked default. + stateStore := store.NewStateStore(db) + seedStates := []struct { + name, group, color string + def bool + seq float64 + }{ + {"Backlog", "backlog", "#94a3b8", false, 1000}, + {"Todo", "unstarted", "#6366f1", true, 2000}, + {"In Progress", "started", "#f59e0b", false, 3000}, + {"Done", "completed", "#22c55e", false, 4000}, + {"Cancelled", "cancelled", "#ef4444", false, 5000}, + } + for _, st := range seedStates { + m := &model.State{ + Name: st.name, Group: st.group, Color: st.color, Default: st.def, + Sequence: st.seq, ProjectID: proj.ID, WorkspaceID: wrk.ID, + } + if err := stateStore.RestoreOrCreateByNameAndProject(ctx, m); err != nil { + return fmt.Errorf("create state %q: %w", st.name, err) + } + } + allStates, err := stateStore.ListByProjectID(ctx, proj.ID) + if err != nil { + return fmt.Errorf("list seeded states: %w", err) + } + stateByName := map[string]uuid.UUID{} + for i := range allStates { + stateByName[allStates[i].Name] = allStates[i].ID + } + + issueSvc := service.NewIssueService(store.NewIssueStore(db), store.NewProjectStore(db), store.NewWorkspaceStore(db)) + issueSvc.SetActivityStore(store.NewIssueActivityStore(db)) + issueSvc.SetStateStore(stateStore) + issueSvc.SetLabelStore(store.NewLabelStore(db)) + + seedIssues := []struct { + name, desc, priority, state string + }{ + {"Welcome to Devlane 👋", "This is a sample work item. Open it to see the detail view, then try editing the state, priority, and assignees.", "high", "Todo"}, + {"Set up your first project", "Projects group work items. Create your own from the sidebar when you're ready.", "medium", "In Progress"}, + {"Explore the board and list layouts", "Switch layouts from the work-item view to see the same issues grouped differently.", "low", "Backlog"}, + {"Try importing issues from CSV", "The project work-item list has an Import CSV action for bulk-adding items.", "none", "Backlog"}, + {"Mark something Done", "Move a work item to the Done state to see it settle.", "medium", "Done"}, + } + created := 0 + for _, di := range seedIssues { + var stateID *uuid.UUID + if id, ok := stateByName[di.state]; ok { + id := id + stateID = &id + } + if _, err := issueSvc.Create(ctx, wrk.Slug, proj.ID, user.ID, + di.name, di.desc, di.priority, stateID, nil, nil, nil, nil, nil, false); err != nil { + return fmt.Errorf("create issue %q: %w", di.name, err) + } + created++ + } + + fmt.Printf("seed: created user %s (password %s), workspace %q, project %q with %d work items\n", + seedEmail, seedPassword, seedWorkspaceSlug, seedProjectIdent, created) + fmt.Printf("seed: sign in at the web app with %s / %s\n", seedEmail, seedPassword) + return nil +} diff --git a/apps/api/cmd/api/seed_test.go b/apps/api/cmd/api/seed_test.go new file mode 100644 index 00000000..a5b7a3a7 --- /dev/null +++ b/apps/api/cmd/api/seed_test.go @@ -0,0 +1,55 @@ +package main + +import ( + "context" + "testing" + + "github.com/Devlaner/devlane/api/internal/store" + "github.com/Devlaner/devlane/api/internal/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The local-dev seed creates a demo user, workspace, project, states, and +// issues, and is idempotent on a second run. Covers #24. +func TestSeedDevData_CreatesDemoAndIsIdempotent(t *testing.T) { + ts := testutil.NewTestServer(t) + ctx := context.Background() + + require.NoError(t, seedDevData(ctx, ts.DB)) + + users := store.NewUserStore(ts.DB) + u, err := users.GetByEmail(ctx, seedEmail) + require.NoError(t, err) + require.NotNil(t, u, "demo user should exist") + + ws := store.NewWorkspaceStore(ts.DB) + wrk, err := ws.GetBySlug(ctx, seedWorkspaceSlug) + require.NoError(t, err) + require.NotNil(t, wrk) + + projects, err := store.NewProjectStore(ts.DB).ListByWorkspaceID(ctx, wrk.ID) + require.NoError(t, err) + require.Len(t, projects, 1) + + states, err := store.NewStateStore(ts.DB).ListByProjectID(ctx, projects[0].ID) + require.NoError(t, err) + require.Len(t, states, 5) + defaults := 0 + for _, s := range states { + if s.Default { + defaults++ + } + } + assert.Equal(t, 1, defaults, "exactly one default state") + + issues, err := store.NewIssueStore(ts.DB).ListByProjectID(ctx, projects[0].ID, 100, 0) + require.NoError(t, err) + assert.Len(t, issues, 5) + + // Second run is a no-op: no error and no duplicate workspace/issues. + require.NoError(t, seedDevData(ctx, ts.DB)) + issues2, err := store.NewIssueStore(ts.DB).ListByProjectID(ctx, projects[0].ID, 100, 0) + require.NoError(t, err) + assert.Len(t, issues2, 5, "second seed should not add issues") +}