From f3c5b43190b1b8e2dfa807bff2176f24ecaf9760 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 17:04:33 +0400 Subject: [PATCH 1/2] feat(api): add local-dev seed command and document it Add `api seed`, a subcommand that populates a local database with a demo user (demo@devlane.test / Demo1234!), a workspace, a project with the standard workflow states, and a handful of sample work items, so a fresh clone has something to explore without clicking through first-run setup. It's idempotent: a second run is a no-op once the demo user exists, and it only seeds instance admin/settings when the instance isn't set up yet. Document it in the root README, CONTRIBUTING (Local development), and the API README commands. Closes #24 Co-Authored-By: Claude Opus 4.8 (1M context) --- CONTRIBUTING.md | 8 +- README.md | 2 +- apps/api/README.md | 7 ++ apps/api/cmd/api/main.go | 4 + apps/api/cmd/api/seed.go | 170 ++++++++++++++++++++++++++++++++++ apps/api/cmd/api/seed_test.go | 55 +++++++++++ 6 files changed, 244 insertions(+), 2 deletions(-) create mode 100644 apps/api/cmd/api/seed.go create mode 100644 apps/api/cmd/api/seed_test.go 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..022a39b7 --- /dev/null +++ b/apps/api/cmd/api/seed.go @@ -0,0 +1,170 @@ +package main + +import ( + "context" + "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. + if u, _ := userStore.GetByEmail(ctx, seedEmail); u != 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) + if n, _ := admins.CountActive(ctx); n == 0 { + _ = admins.Create(ctx, &model.InstanceAdmin{UserID: user.ID, Role: model.RoleOwner, IsVerified: true}) + } + settings := store.NewInstanceSettingStore(db) + if row, _ := settings.Get(ctx, "general"); row == nil { + _ = settings.Upsert(ctx, "general", model.JSONMap{ + "instance_id": "devlocalseed00000000000", + "admin_email": seedEmail, + "instance_name": "Devlane (local)", + "only_admin_can_create_workspace": false, + }) + } + + 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) + } + } + stateByName := map[string]uuid.UUID{} + if all, err := stateStore.ListByProjectID(ctx, proj.ID); err == nil { + for i := range all { + stateByName[all[i].Name] = all[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") +} From d9715fb11eeb8af8e7b080d4f788fa6dd35ccc24 Mon Sep 17 00:00:00 2001 From: cavidelizade Date: Mon, 13 Jul 2026 17:50:02 +0400 Subject: [PATCH 2/2] fix(api): surface store errors in the dev seed instead of swallowing them The seed discarded errors from the user lookup, instance-admin count/create, settings read/write, and state listing. A transient DB error would then be misread (e.g. "needs seeding") and could leave a half-seeded instance. Each is now checked and wrapped, treating only gorm.ErrRecordNotFound as "absent". Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/cmd/api/seed.go | 41 ++++++++++++++++++++++++++++++---------- 1 file changed, 31 insertions(+), 10 deletions(-) diff --git a/apps/api/cmd/api/seed.go b/apps/api/cmd/api/seed.go index 022a39b7..751b5e27 100644 --- a/apps/api/cmd/api/seed.go +++ b/apps/api/cmd/api/seed.go @@ -2,6 +2,7 @@ package main import ( "context" + "errors" "fmt" "log/slog" "os" @@ -62,7 +63,13 @@ 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. - if u, _ := userStore.GetByEmail(ctx, seedEmail); u != nil { + // 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 } @@ -81,17 +88,29 @@ func seedDevData(ctx context.Context, db *gorm.DB) error { // 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) - if n, _ := admins.CountActive(ctx); n == 0 { - _ = admins.Create(ctx, &model.InstanceAdmin{UserID: user.ID, Role: model.RoleOwner, IsVerified: true}) + 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) - if row, _ := settings.Get(ctx, "general"); row == nil { - _ = settings.Upsert(ctx, "general", model.JSONMap{ + 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) @@ -128,11 +147,13 @@ func seedDevData(ctx context.Context, db *gorm.DB) error { 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{} - if all, err := stateStore.ListByProjectID(ctx, proj.ID); err == nil { - for i := range all { - stateByName[all[i].Name] = all[i].ID - } + for i := range allStates { + stateByName[allStates[i].Name] = allStates[i].ID } issueSvc := service.NewIssueService(store.NewIssueStore(db), store.NewProjectStore(db), store.NewWorkspaceStore(db))