diff --git a/.github/instructions/backend-app-api-handlers.instructions.md b/.github/instructions/backend-app-api-handlers.instructions.md new file mode 100644 index 00000000..96246cf0 --- /dev/null +++ b/.github/instructions/backend-app-api-handlers.instructions.md @@ -0,0 +1,432 @@ +--- +applyTo: '/backend/app/api/handlers/**/*' +--- + +# Backend API Handlers Instructions (`/backend/app/api/handlers/v1/`) + +## Overview + +API handlers are the HTTP layer that processes requests, calls services, and returns responses. All handlers use the V1 API pattern with Swagger documentation for auto-generation. + +## Architecture Flow + +``` +HTTP Request → Router → Middleware → Handler → Service → Repository → Database + ↓ + HTTP Response +``` + +## Directory Structure + +``` +backend/app/api/ +├── routes.go # Route definitions and middleware +├── handlers/ +│ └── v1/ +│ ├── controller.go # V1Controller struct and dependencies +│ ├── v1_ctrl_items.go # Item endpoints +│ ├── v1_ctrl_users.go # User endpoints +│ ├── v1_ctrl_locations.go # Location endpoints +│ ├── v1_ctrl_auth.go # Authentication endpoints +│ ├── helpers.go # HTTP helper functions +│ ├── query_params.go # Query parameter parsing +│ └── assets/ # Asset handling +``` + +## Handler Structure + +### V1Controller + +All handlers are methods on `V1Controller`: + +```go +type V1Controller struct { + svc *services.AllServices // Service layer + repo *repo.AllRepos // Direct repo access (rare) + bus *eventbus.EventBus // Event publishing +} + +func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // Handler logic + } +} +``` + +### Swagger Documentation + +**CRITICAL:** Every handler must have Swagger comments for API doc generation: + +```go +// HandleItemsGetAll godoc +// +// @Summary Query All Items +// @Tags Items +// @Produce json +// @Param q query string false "search string" +// @Param page query int false "page number" +// @Param pageSize query int false "items per page" +// @Success 200 {object} repo.PaginationResult[repo.ItemSummary]{} +// @Router /v1/items [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // ... + } +} +``` + +**After modifying Swagger comments, ALWAYS run:** +```bash +task generate # Regenerates Swagger docs and TypeScript types +``` + +## Standard Handler Pattern + +### 1. Decode Request + +```go +func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + var itemData repo.ItemCreate + if err := server.Decode(r, &itemData); err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + // ... rest of handler + } +} +``` + +### 2. Extract Context + +```go +// Get current user from request (added by auth middleware) +user := ctrl.CurrentUser(r) + +// Create service context with group/user IDs +ctx := services.NewContext(r.Context(), user) +``` + +### 3. Call Service + +```go +result, err := ctrl.svc.Items.Create(ctx, itemData) +if err != nil { + return validate.NewRequestError(err, http.StatusInternalServerError) +} +``` + +### 4. Return Response + +```go +return server.JSON(w, result, http.StatusCreated) +``` + +## Common Handler Patterns + +### GET - Single Item + +```go +// HandleItemGet godoc +// +// @Summary Get Item +// @Tags Items +// @Produce json +// @Param id path string true "Item ID" +// @Success 200 {object} repo.ItemOut +// @Router /v1/items/{id} [GET] +// @Security Bearer +func (ctrl *V1Controller) HandleItemGet() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + id, err := ctrl.RouteUUID(r, "id") + if err != nil { + return err + } + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + item, err := ctrl.svc.Items.Get(ctx, id) + if err != nil { + return validate.NewRequestError(err, http.StatusNotFound) + } + + return server.JSON(w, item, http.StatusOK) + } +} +``` + +### GET - List with Pagination + +```go +func (ctrl *V1Controller) HandleItemsGetAll() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + // Parse query parameters + query := extractItemQuery(r) + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + items, err := ctrl.svc.Items.GetAll(ctx, query) + if err != nil { + return err + } + + return server.JSON(w, items, http.StatusOK) + } +} + +// Helper to extract query params +func extractItemQuery(r *http.Request) repo.ItemQuery { + params := r.URL.Query() + return repo.ItemQuery{ + Page: queryIntOrNegativeOne(params.Get("page")), + PageSize: queryIntOrNegativeOne(params.Get("pageSize")), + Search: params.Get("q"), + LocationIDs: queryUUIDList(params, "locations"), + } +} +``` + +### POST - Create + +```go +// HandleItemCreate godoc +// +// @Summary Create Item +// @Tags Items +// @Accept json +// @Produce json +// @Param payload body repo.ItemCreate true "Item Data" +// @Success 201 {object} repo.ItemOut +// @Router /v1/items [POST] +// @Security Bearer +func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + var data repo.ItemCreate + if err := server.Decode(r, &data); err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + item, err := ctrl.svc.Items.Create(ctx, data) + if err != nil { + return err + } + + return server.JSON(w, item, http.StatusCreated) + } +} +``` + +### PUT - Update + +```go +// HandleItemUpdate godoc +// +// @Summary Update Item +// @Tags Items +// @Accept json +// @Produce json +// @Param id path string true "Item ID" +// @Param payload body repo.ItemUpdate true "Item Data" +// @Success 200 {object} repo.ItemOut +// @Router /v1/items/{id} [PUT] +// @Security Bearer +func (ctrl *V1Controller) HandleItemUpdate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + id, err := ctrl.RouteUUID(r, "id") + if err != nil { + return err + } + + var data repo.ItemUpdate + if err := server.Decode(r, &data); err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + item, err := ctrl.svc.Items.Update(ctx, id, data) + if err != nil { + return err + } + + return server.JSON(w, item, http.StatusOK) + } +} +``` + +### DELETE + +```go +// HandleItemDelete godoc +// +// @Summary Delete Item +// @Tags Items +// @Param id path string true "Item ID" +// @Success 204 +// @Router /v1/items/{id} [DELETE] +// @Security Bearer +func (ctrl *V1Controller) HandleItemDelete() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + id, err := ctrl.RouteUUID(r, "id") + if err != nil { + return err + } + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + err = ctrl.svc.Items.Delete(ctx, id) + if err != nil { + return err + } + + return server.JSON(w, nil, http.StatusNoContent) + } +} +``` + +### File Upload + +```go +func (ctrl *V1Controller) HandleItemAttachmentCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + id, err := ctrl.RouteUUID(r, "id") + if err != nil { + return err + } + + // Parse multipart form + err = r.ParseMultipartForm(32 << 20) // 32MB max + if err != nil { + return err + } + + file, header, err := r.FormFile("file") + if err != nil { + return err + } + defer file.Close() + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + attachment, err := ctrl.svc.Items.CreateAttachment(ctx, id, file, header.Filename) + if err != nil { + return err + } + + return server.JSON(w, attachment, http.StatusCreated) + } +} +``` + +## Routing + +Routes are defined in `backend/app/api/routes.go`: + +```go +func (a *app) mountRoutes(repos *repo.AllRepos, svc *services.AllServices) { + v1 := v1.NewControllerV1(svc, repos) + + a.server.Get("/api/v1/items", v1.HandleItemsGetAll()) + a.server.Post("/api/v1/items", v1.HandleItemCreate()) + a.server.Get("/api/v1/items/{id}", v1.HandleItemGet()) + a.server.Put("/api/v1/items/{id}", v1.HandleItemUpdate()) + a.server.Delete("/api/v1/items/{id}", v1.HandleItemDelete()) +} +``` + +## Helper Functions + +### Query Parameter Parsing + +Located in `query_params.go`: + +```go +func queryIntOrNegativeOne(s string) int +func queryBool(s string) bool +func queryUUIDList(params url.Values, key string) []uuid.UUID +``` + +### Response Helpers + +```go +// From httpkit/server +server.JSON(w, data, statusCode) // JSON response +server.Respond(w, statusCode) // Empty response +validate.NewRequestError(err, statusCode) // Error response +``` + +### Authentication + +```go +user := ctrl.CurrentUser(r) // Get authenticated user (from middleware) +``` + +## Adding a New Endpoint + +### 1. Create Handler + +In `backend/app/api/handlers/v1/v1_ctrl_myentity.go`: + +```go +// HandleMyEntityCreate godoc +// +// @Summary Create MyEntity +// @Tags MyEntity +// @Accept json +// @Produce json +// @Param payload body repo.MyEntityCreate true "Data" +// @Success 201 {object} repo.MyEntityOut +// @Router /v1/my-entity [POST] +// @Security Bearer +func (ctrl *V1Controller) HandleMyEntityCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + var data repo.MyEntityCreate + if err := server.Decode(r, &data); err != nil { + return validate.NewRequestError(err, http.StatusBadRequest) + } + + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + result, err := ctrl.svc.MyEntity.Create(ctx, data) + if err != nil { + return err + } + + return server.JSON(w, result, http.StatusCreated) + } +} +``` + +### 2. Add Route + +In `backend/app/api/routes.go`: + +```go +a.server.Post("/api/v1/my-entity", v1.HandleMyEntityCreate()) +``` + +### 3. Generate Docs + +```bash +task generate # Generates Swagger docs and TypeScript types +``` + +### 4. Test + +```bash +task go:build # Verify builds +task go:test # Run tests +``` + +## Critical Rules + +1. **ALWAYS add Swagger comments** - required for API docs and TypeScript type generation +2. **Run `task generate` after handler changes** - updates API documentation +3. **Use services, not repos directly** - handlers call services, services call repos +4. **Always use `services.Context`** - includes auth and multi-tenancy +5. **Handle errors properly** - use `validate.NewRequestError()` with appropriate status codes +6. **Validate input** - decode and validate request bodies +7. **Return correct status codes** - 200 OK, 201 Created, 204 No Content, 400 Bad Request, 404 Not Found + +## Common Issues + +- **"Missing Swagger docs"** → Add `@Summary`, `@Tags`, `@Router` comments, run `task generate` +- **TypeScript types outdated** → Run `task generate` to regenerate +- **Auth failures** → Ensure route has auth middleware and `@Security Bearer` +- **CORS errors** → Check middleware configuration in `routes.go` diff --git a/.github/instructions/backend-internal-core-services.instructions.md b/.github/instructions/backend-internal-core-services.instructions.md new file mode 100644 index 00000000..79c355f5 --- /dev/null +++ b/.github/instructions/backend-internal-core-services.instructions.md @@ -0,0 +1,341 @@ +--- +applyTo: '/backend/internal/core/services/**/*' +--- + +# Backend Services Layer Instructions (`/backend/internal/core/services/`) + +## Overview + +The services layer contains business logic that orchestrates between repositories and API handlers. Services handle complex operations, validation, and cross-cutting concerns. + +## Architecture Pattern + +``` +Handler (API) → Service (Business Logic) → Repository (Data Access) → Database +``` + +**Separation of concerns:** +- **Handlers** (`backend/app/api/handlers/v1/`) - HTTP request/response, routing, auth +- **Services** (`backend/internal/core/services/`) - Business logic, orchestration +- **Repositories** (`backend/internal/data/repo/`) - Database operations, queries + +## Directory Structure + +``` +backend/internal/core/services/ +├── all.go # Service aggregation +├── service_items.go # Item business logic +├── service_items_attachments.go # Item attachments logic +├── service_user.go # User management logic +├── service_group.go # Group management logic +├── service_background.go # Background tasks +├── contexts.go # Service context types +├── reporting/ # Reporting subsystem +│ ├── eventbus/ # Event bus for notifications +│ └── *.go # Report generation logic +└── *_test.go # Service tests +``` + +## Service Structure + +### Standard Pattern + +```go +type ItemService struct { + repo *repo.AllRepos // Access to all repositories + filepath string // File storage path + autoIncrementAssetID bool // Feature flags +} + +func (svc *ItemService) Create(ctx Context, item repo.ItemCreate) (repo.ItemOut, error) { + // 1. Validation + if item.Name == "" { + return repo.ItemOut{}, errors.New("name required") + } + + // 2. Business logic + if svc.autoIncrementAssetID { + highest, err := svc.repo.Items.GetHighestAssetID(ctx, ctx.GID) + if err != nil { + return repo.ItemOut{}, err + } + item.AssetID = highest + 1 + } + + // 3. Repository call + return svc.repo.Items.Create(ctx, ctx.GID, item) +} +``` + +### Service Context + +Services use a custom `Context` type that extends `context.Context`: + +```go +type Context struct { + context.Context + GID uuid.UUID // Group ID for multi-tenancy + UID uuid.UUID // User ID for audit +} +``` + +**Always use `Context` from services package, not raw `context.Context`.** + +## Common Service Patterns + +### 1. CRUD with Business Logic + +```go +func (svc *ItemService) Update(ctx Context, id uuid.UUID, data repo.ItemUpdate) (repo.ItemOut, error) { + // Fetch existing + existing, err := svc.repo.Items.Get(ctx, id) + if err != nil { + return repo.ItemOut{}, err + } + + // Business rules + if existing.Archived && data.Quantity != nil { + return repo.ItemOut{}, errors.New("cannot modify archived items") + } + + // Update + return svc.repo.Items.Update(ctx, id, data) +} +``` + +### 2. Orchestrating Multiple Repositories + +```go +func (svc *ItemService) CreateWithAttachment(ctx Context, item repo.ItemCreate, file io.Reader) (repo.ItemOut, error) { + // Create item + created, err := svc.repo.Items.Create(ctx, ctx.GID, item) + if err != nil { + return repo.ItemOut{}, err + } + + // Upload attachment + attachment, err := svc.repo.Attachments.Create(ctx, created.ID, file) + if err != nil { + // Rollback - delete item + _ = svc.repo.Items.Delete(ctx, created.ID) + return repo.ItemOut{}, err + } + + created.Attachments = []repo.AttachmentOut{attachment} + return created, nil +} +``` + +### 3. Background Tasks + +```go +func (svc *ItemService) EnsureAssetID(ctx context.Context, gid uuid.UUID) (int, error) { + // Get items without asset IDs + items, err := svc.repo.Items.GetAllZeroAssetID(ctx, gid) + if err != nil { + return 0, err + } + + // Batch assign + highest := svc.repo.Items.GetHighestAssetID(ctx, gid) + for _, item := range items { + highest++ + _ = svc.repo.Items.Update(ctx, item.ID, repo.ItemUpdate{ + AssetID: &highest, + }) + } + + return len(items), nil +} +``` + +### 4. Event Publishing + +Services can publish events to the event bus: + +```go +func (svc *ItemService) Delete(ctx Context, id uuid.UUID) error { + err := svc.repo.Items.Delete(ctx, id) + if err != nil { + return err + } + + // Publish event for notifications + svc.repo.Bus.Publish(eventbus.Event{ + Type: "item.deleted", + Data: map[string]interface{}{"id": id}, + }) + + return nil +} +``` + +## Service Aggregation + +All services are bundled in `all.go`: + +```go +type AllServices struct { + User *UserService + Group *GroupService + Items *ItemService + // ... other services +} + +func New(repos *repo.AllRepos, filepath string) *AllServices { + return &AllServices{ + User: &UserService{repo: repos}, + Items: &ItemService{repo: repos, filepath: filepath}, + // ... + } +} +``` + +**Accessed in handlers via:** +```go +ctrl.svc.Items.Create(ctx, itemData) +``` + +## Working with Services from Handlers + +Handlers call services, not repositories directly: + +```go +// In backend/app/api/handlers/v1/v1_ctrl_items.go +func (ctrl *V1Controller) HandleItemCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + var itemData repo.ItemCreate + if err := server.Decode(r, &itemData); err != nil { + return err + } + + // Get context with group/user IDs + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + + // Call service (not repository) + item, err := ctrl.svc.Items.Create(ctx, itemData) + if err != nil { + return err + } + + return server.JSON(w, item, http.StatusCreated) + } +} +``` + +## Testing Services + +Service tests mock repositories using interfaces: + +```go +func TestItemService_Create(t *testing.T) { + mockRepo := &mockItemRepo{ + CreateFunc: func(ctx context.Context, gid uuid.UUID, data repo.ItemCreate) (repo.ItemOut, error) { + return repo.ItemOut{ID: uuid.New(), Name: data.Name}, nil + }, + } + + svc := &ItemService{repo: &repo.AllRepos{Items: mockRepo}} + + ctx := services.Context{GID: uuid.New(), UID: uuid.New()} + result, err := svc.Create(ctx, repo.ItemCreate{Name: "Test"}) + + assert.NoError(t, err) + assert.Equal(t, "Test", result.Name) +} +``` + +**Run service tests:** +```bash +cd backend && go test ./internal/core/services -v +``` + +## Adding a New Service + +### 1. Create Service File + +Create `backend/internal/core/services/service_myentity.go`: + +```go +package services + +type MyEntityService struct { + repo *repo.AllRepos +} + +func (svc *MyEntityService) Create(ctx Context, data repo.MyEntityCreate) (repo.MyEntityOut, error) { + // Business logic here + return svc.repo.MyEntity.Create(ctx, ctx.GID, data) +} +``` + +### 2. Add to AllServices + +Edit `backend/internal/core/services/all.go`: + +```go +type AllServices struct { + // ... existing services + MyEntity *MyEntityService +} + +func New(repos *repo.AllRepos, filepath string) *AllServices { + return &AllServices{ + // ... existing services + MyEntity: &MyEntityService{repo: repos}, + } +} +``` + +### 3. Use in Handler + +In `backend/app/api/handlers/v1/`: + +```go +func (ctrl *V1Controller) HandleMyEntityCreate() errchain.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) error { + ctx := services.NewContext(r.Context(), ctrl.CurrentUser(r)) + result, err := ctrl.svc.MyEntity.Create(ctx, data) + // ... + } +} +``` + +### 4. Run Tests + +```bash +task generate # If you modified schemas +task go:test # Run all tests +``` + +## Common Service Responsibilities + +**Services should:** +- ✅ Contain business logic and validation +- ✅ Orchestrate multiple repository calls +- ✅ Handle transactions (when needed) +- ✅ Publish events for side effects +- ✅ Enforce access control and multi-tenancy +- ✅ Transform data between API and repository formats + +**Services should NOT:** +- ❌ Handle HTTP requests/responses (that's handlers) +- ❌ Construct SQL queries (that's repositories) +- ❌ Import handler packages (creates circular deps) +- ❌ Directly access database (use repositories) + +## Critical Rules + +1. **Always use `services.Context`** - includes group/user IDs for multi-tenancy +2. **Services call repos, handlers call services** - maintains layer separation +3. **No direct database access** - always through repositories +4. **Business logic goes here** - not in handlers or repositories +5. **Test services independently** - mock repository dependencies + +## Common Patterns to Follow + +- **Validation:** Check business rules before calling repository +- **Error wrapping:** Add context to repository errors +- **Logging:** Use `log.Ctx(ctx)` for contextual logging +- **Transactions:** Use `repo.WithTx()` for multi-step operations +- **Events:** Publish to event bus for notifications/side effects diff --git a/.github/instructions/backend-internal-data.instructions.md b/.github/instructions/backend-internal-data.instructions.md new file mode 100644 index 00000000..f70bf0d3 --- /dev/null +++ b/.github/instructions/backend-internal-data.instructions.md @@ -0,0 +1,239 @@ +--- +applyTo: 'backend/internal/data/**/*' +--- + + +# Backend Data Layer Instructions (`/backend/internal/data/`) + +## Overview + +This directory contains the data access layer using **Ent ORM** (entity framework). It follows a clear separation between schema definitions, generated code, and repository implementations. + +## Directory Structure + +``` +backend/internal/data/ +├── ent/ # Ent ORM generated code (DO NOT EDIT) +│ ├── schema/ # Schema definitions (EDIT THESE) +│ │ ├── item.go # Item entity schema +│ │ ├── user.go # User entity schema +│ │ ├── location.go # Location entity schema +│ │ ├── label.go # Label entity schema +│ │ └── mixins/ # Reusable schema mixins +│ ├── *.go # Generated entity code +│ └── migrate/ # Generated migrations +├── repo/ # Repository pattern implementations +│ ├── repos_all.go # Aggregates all repositories +│ ├── repo_items.go # Item repository +│ ├── repo_users.go # User repository +│ ├── repo_locations.go # Location repository +│ └── *_test.go # Repository tests +├── migrations/ # Manual SQL migrations +│ ├── sqlite3/ # SQLite-specific migrations +│ └── postgres/ # PostgreSQL-specific migrations +└── types/ # Custom data types +``` + +## Ent ORM Workflow + +### 1. Defining Schemas (`ent/schema/`) + +**ALWAYS edit schema files here** - these define your database entities: + +```go +// Example: backend/internal/data/ent/schema/item.go +type Item struct { + ent.Schema +} + +func (Item) Fields() []ent.Field { + return []ent.Field{ + field.String("name").NotEmpty(), + field.Int("quantity").Default(1), + field.Bool("archived").Default(false), + } +} + +func (Item) Edges() []ent.Edge { + return []ent.Edge{ + edge.From("location", Location.Type).Ref("items").Unique(), + edge.From("labels", Label.Type).Ref("items"), + } +} + +func (Item) Indexes() []ent.Index { + return []ent.Index{ + index.Fields("name"), + index.Fields("archived"), + } +} +``` + +**Common schema patterns:** +- Use `mixins.BaseMixin{}` for `id`, `created_at`, `updated_at` fields +- Use `mixins.DetailsMixin{}` for `name` and `description` fields +- Use `GroupMixin{ref: "items"}` to link entities to groups +- Add indexes for frequently queried fields + +### 2. Generating Code + +**After modifying any schema file, ALWAYS run:** + +```bash +task generate +``` + +This: +1. Runs `go generate ./...` in `backend/internal/` (generates Ent code) +2. Generates Swagger docs from API handlers +3. Generates TypeScript types for frontend + +**Generated files you'll see:** +- `ent/*.go` - Entity types, builders, queries +- `ent/migrate/migrate.go` - Auto migrations +- `ent/predicate/predicate.go` - Query predicates + +**NEVER edit generated files directly** - changes will be overwritten. + +### 3. Using Generated Code in Repositories + +Repositories in `repo/` use the generated Ent client: + +```go +// Example: backend/internal/data/repo/repo_items.go +type ItemsRepository struct { + db *ent.Client + bus *eventbus.EventBus +} + +func (r *ItemsRepository) Create(ctx context.Context, gid uuid.UUID, data ItemCreate) (ItemOut, error) { + entity, err := r.db.Item.Create(). + SetName(data.Name). + SetQuantity(data.Quantity). + SetGroupID(gid). + Save(ctx) + + return mapToItemOut(entity), err +} +``` + +## Repository Pattern + +### Structure + +Each entity typically has: +- **Repository struct** (`ItemsRepository`) - holds DB client and dependencies +- **Input types** (`ItemCreate`, `ItemUpdate`) - API input DTOs +- **Output types** (`ItemOut`, `ItemSummary`) - API response DTOs +- **Query types** (`ItemQuery`) - search/filter parameters +- **Mapper functions** (`mapToItemOut`) - converts Ent entities to output DTOs + +### Key Methods + +Repositories typically implement: +- `Create(ctx, gid, input)` - Create new entity +- `Get(ctx, id)` - Get single entity by ID +- `GetAll(ctx, gid, query)` - Query with pagination/filters +- `Update(ctx, id, input)` - Update entity +- `Delete(ctx, id)` - Delete entity + +### Working with Ent Queries + +**Loading relationships (edges):** +```go +items, err := r.db.Item.Query(). + WithLocation(). // Load location edge + WithLabels(). // Load labels edge + WithChildren(). // Load child items + Where(item.GroupIDEQ(gid)). + All(ctx) +``` + +**Filtering:** +```go +query := r.db.Item.Query(). + Where( + item.GroupIDEQ(gid), + item.ArchivedEQ(false), + item.NameContainsFold(search), + ) +``` + +**Ordering and pagination:** +```go +items, err := query. + Order(ent.Desc(item.FieldCreatedAt)). + Limit(pageSize). + Offset((page - 1) * pageSize). + All(ctx) +``` + +## Common Workflows + +### Adding a New Entity + +1. **Create schema:** `backend/internal/data/ent/schema/myentity.go` +2. **Run:** `task generate` (generates Ent code) +3. **Create repository:** `backend/internal/data/repo/repo_myentity.go` +4. **Add to AllRepos:** Edit `repo/repos_all.go` to include new repo +5. **Run tests:** `task go:test` + +### Adding Fields to Existing Entity + +1. **Edit schema:** `backend/internal/data/ent/schema/item.go` + ```go + field.String("new_field").Optional() + ``` +2. **Run:** `task generate` +3. **Update repository:** Add field to input/output types in `repo/repo_items.go` +4. **Update mappers:** Ensure mapper functions handle new field +5. **Run tests:** `task go:test` + +### Adding Relationships (Edges) + +1. **Edit both schemas:** + ```go + // In item.go + edge.From("location", Location.Type).Ref("items").Unique() + + // In location.go + edge.To("items", Item.Type) + ``` +2. **Run:** `task generate` +3. **Use in queries:** `.WithLocation()` to load the edge +4. **Run tests:** `task go:test` + +## Testing + +Repository tests use `enttest` for in-memory SQLite: + +```go +func TestItemRepo(t *testing.T) { + client := enttest.Open(t, "sqlite3", "file:ent?mode=memory&_fk=1") + defer client.Close() + + repo := &ItemsRepository{db: client} + // Test methods... +} +``` + +**Run repository tests:** +```bash +cd backend && go test ./internal/data/repo -v +``` + +## Critical Rules + +1. **ALWAYS run `task generate` after schema changes** - builds will fail otherwise +2. **NEVER edit files in `ent/` except `ent/schema/`** - they're generated +3. **Use repositories, not raw Ent queries in services/handlers** - maintains separation +4. **Include `group_id` in all queries** - ensures multi-tenancy +5. **Use `.WithX()` to load edges** - avoids N+1 queries +6. **Test with both SQLite and PostgreSQL** - CI tests both + +## Common Errors + +- **"undefined: ent.ItemX"** → Run `task generate` after schema changes +- **Migration conflicts** → Check `migrations/` for manual migration files +- **Foreign key violations** → Ensure edges are properly defined in both schemas +- **Slow queries** → Add indexes in schema `Indexes()` method diff --git a/.github/instructions/code.instructions.md b/.github/instructions/code.instructions.md new file mode 100644 index 00000000..4e3fa228 --- /dev/null +++ b/.github/instructions/code.instructions.md @@ -0,0 +1,157 @@ +# Homebox Repository Instructions for Coding Agents + +## Repository Overview + +**Type**: Full-stack home inventory management web app (monorepo) +**Size**: ~265 Go files, ~371 TypeScript/Vue files +**Build Tool**: Task (Taskfile.yml) - **ALWAYS use `task` commands** +**Database**: SQLite (default) or PostgreSQL + +### Stack +- **Backend** (`/backend`): Go 1.24+, Chi router, Ent ORM, port 7745 +- **Frontend** (`/frontend`): Nuxt 4, Vue 3, TypeScript, Tailwind CSS, pnpm 9.1.4+, dev proxies to backend + +## Critical Build & Validation Commands + +### Initial Setup (Run Once) +```bash +task setup # Installs swag, goose, Go deps, pnpm deps +``` + +### Code Generation (Required Before Backend Work) +```bash +task generate # Generates Ent ORM, Swagger docs, TypeScript types +``` +**ALWAYS run after**: schema changes, API handler changes, before backend server/tests +**Note**: "TypeSpecDef is nil" warnings are normal - ignore them + +### Backend Commands +```bash +task go:build # Build binary (60-90s) +task go:test # Unit tests (5-10s) +task go:lint # golangci-lint (6m timeout in CI) +task go:all # Tidy + lint + test +task go:run # Start server (SQLite) +task pr # Full PR validation (3-5 min) +``` + +### Frontend Commands +```bash +task ui:dev # Dev server port 3000 +task ui:check # Type checking +task ui:fix # eslint --fix + prettier +task ui:watch # Vitest watch mode +``` +**Lint**: Max 1 warning in CI (`pnpm run lint:ci`) + +### Testing +```bash +task test:ci # Integration tests (15-30s + startup) +task test:e2e # Playwright E2E (60s+ per shard, needs playwright install) +task pr # Full PR validation: generate + go:all + ui:check + ui:fix + test:ci (3-5 min) +``` + +## Project Structure + +### Key Root Files +- `Taskfile.yml` - All commands (always use `task`) +- `docker-compose.yml`, `Dockerfile*` - Docker configs +- `CONTRIBUTING.md` - Contribution guidelines + +### Backend Structure (`/backend`) +``` +backend/ +├── app/ +│ ├── api/ # Main API application +│ │ ├── main.go # Entry point +│ │ ├── routes.go # Route definitions +│ │ ├── handlers/ # HTTP handlers (v1 API) +│ │ ├── static/ # Swagger docs, embedded frontend +│ │ └── providers/ # Service providers +│ └── tools/ +│ └── typegen/ # TypeScript type generation tool +├── internal/ +│ ├── core/ +│ │ └── services/ # Business logic layer +│ ├── data/ +│ │ ├── ent/ # Ent ORM generated code + schemas +│ │ │ └── schema/ # Schema definitions (edit these) +│ │ └── repo/ # Repository pattern implementations +│ ├── sys/ # System utilities (config, validation) +│ └── web/ # Web middleware +├── pkgs/ # Reusable packages +├── go.mod, go.sum # Go dependencies +└── .golangci.yml # Linter configuration +``` + +**Patterns**: Schema/API changes → edit source → `task generate`. Never edit generated code in `ent/`. + +### Frontend Structure (`/frontend`) +``` +frontend/ +├── app.vue # Root component +├── nuxt.config.ts # Nuxt configuration +├── package.json # Frontend dependencies +├── components/ # Vue components (auto-imported) +├── pages/ # File-based routing +├── layouts/ # Layout components +├── composables/ # Vue composables (auto-imported) +├── stores/ # Pinia state stores +├── lib/ +│ └── api/ +│ └── types/ # Generated TypeScript API types +├── locales/ # i18n translations +├── test/ # Vitest + Playwright tests +├── eslint.config.mjs # ESLint configuration +└── tailwind.config.js # Tailwind configuration +``` + +**Patterns**: Auto-imports for `components/` and `composables/`. API types auto-generated - never edit manually. + +## CI/CD Workflows + +PR checks (`.github/workflows/pull-requests.yaml`) on `main`/`vnext`: +1. **Backend**: Go 1.24, golangci-lint, `task go:build`, `task go:coverage` +2. **Frontend**: Lint (max 1 warning), typecheck, `task test:ci` (SQLite + PostgreSQL v15-17) +3. **E2E**: 4 sharded Playwright runs (60min timeout) + +All must pass before merge. + +## Common Pitfalls + +1. **Missing tools**: Run `task setup` first (installs swag, goose, deps) +2. **Stale generated code**: Always `task generate` after schema/API changes +3. **Test failures**: Integration tests may fail first run (race condition) - retry +4. **Port in use**: Backend uses 7745 - kill existing process +5. **SQLite locked**: Delete `.data/homebox.db-*` files +6. **Clean build**: `rm -rf build/ backend/app/api/static/public/ frontend/.nuxt` + +## Environment Variables + +Backend defaults in `Taskfile.yml`: +- `HBOX_LOG_LEVEL=debug` +- `HBOX_DATABASE_DRIVER=sqlite3` (or `postgres`) +- `HBOX_DATABASE_SQLITE_PATH=.data/homebox.db?_pragma=busy_timeout=1000&_pragma=journal_mode=WAL&_fk=1` +- PostgreSQL: `HBOX_DATABASE_*` vars for username/password/host/port/database + +## Validation Checklist + +Before PR: +- [ ] `task generate` after schema/API changes +- [ ] `task pr` passes (includes lint, test, typecheck) +- [ ] No build artifacts committed (check `.gitignore`) +- [ ] Code matches existing patterns + +## Quick Reference + +**Dev environment**: `task go:run` (terminal 1) + `task ui:dev` (terminal 2) + +**API changes**: Edit handlers → add Swagger comments → `task generate` → `task go:build` → `task go:test` + +**Schema changes**: Edit `ent/schema/*.go` → `task generate` → update repo methods → `task go:test` + +**Specific tests**: `cd backend && go test ./path -v` or `cd frontend && pnpm run test:watch` + +## Trust These Instructions + +Instructions are validated and current. Only explore further if info is incomplete, incorrect, or you encounter undocumented errors. Use `task --list-all` for all commands. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md new file mode 100644 index 00000000..cd688a0b --- /dev/null +++ b/.github/instructions/frontend.instructions.md @@ -0,0 +1,480 @@ +--- +applyTo: 'frontend/**/*' +--- + + +# Frontend Components & Pages Instructions (`/frontend/`) + +## Overview + +The frontend is a Nuxt 4 application with Vue 3 and TypeScript. It uses auto-imports for components and composables, file-based routing, and generated TypeScript types from the backend API. + +## Directory Structure + +``` +frontend/ +├── components/ # Vue components (auto-imported) +│ ├── Item/ # Item-related components +│ ├── Location/ # Location components +│ ├── Label/ # Label components +│ ├── Form/ # Form components +│ └── ui/ # Shadcn-vue UI components +├── pages/ # File-based routes (auto-routing) +│ ├── index.vue # Home page (/) +│ ├── items.vue # Items list (/items) +│ ├── item/ +│ │ └── [id].vue # Item detail (/item/:id) +│ ├── locations.vue # Locations list (/locations) +│ └── profile.vue # User profile (/profile) +├── composables/ # Vue composables (auto-imported) +│ ├── use-api.ts # API client wrapper +│ ├── use-auth.ts # Authentication +│ └── use-user-api.ts # User API helpers +├── stores/ # Pinia state management +│ ├── auth.ts # Auth state +│ └── preferences.ts # User preferences +├── lib/ +│ └── api/ +│ └── types/ # Generated TypeScript types (DO NOT EDIT) +├── layouts/ # Layout components +│ └── default.vue # Default layout +├── locales/ # i18n translations +├── test/ # Tests (Vitest + Playwright) +└── nuxt.config.ts # Nuxt configuration +``` + +## Auto-Imports + +### Components + +Components in `components/` are **automatically imported** - no import statement needed: + +```vue + + + + + +``` + +**Naming convention:** Nested path becomes component name +- `components/Item/Card.vue` → `` +- `components/Form/TextField.vue` → `` + +### Composables + +Composables in `composables/` are **automatically imported**: + +```ts +// composables/use-items.ts +export function useItems() { + const api = useUserApi() + + async function getItems() { + const { data } = await api.items.getAll() + return data + } + + return { getItems } +} + +// pages/items.vue - NO import needed +const { getItems } = useItems() +const items = await getItems() +``` + +## File-Based Routing + +Pages in `pages/` automatically become routes: + +``` +pages/index.vue → / +pages/items.vue → /items +pages/item/[id].vue → /item/:id +pages/locations.vue → /locations +pages/location/[id].vue → /location/:id +pages/profile.vue → /profile +``` + +### Dynamic Routes + +Use square brackets for dynamic segments: + +```vue + + + + +``` + +## API Integration + +### Generated Types + +API types are auto-generated from backend Swagger docs: + +```ts +// lib/api/types/data-contracts.ts (GENERATED - DO NOT EDIT) +export interface ItemOut { + id: string + name: string + quantity: number + createdAt: Date | string + updatedAt: Date | string +} + +export interface ItemCreate { + name: string + quantity?: number + locationId?: string +} +``` + +**Regenerate after backend API changes:** +```bash +task generate # Runs in backend, updates frontend/lib/api/types/ +``` + +### Using the API Client + +The `useUserApi()` composable provides typed API access: + +```vue + +``` + +## Component Patterns + +### Standard Vue 3 Composition API + +```vue + + + + + +``` + +### Using Pinia Stores + +```vue + +``` + +### Form Handling + +```vue + + + +``` + +## Styling + +### Tailwind CSS + +The project uses Tailwind CSS for styling: + +```vue + +``` + +### Shadcn-vue Components + +UI components from `components/ui/` (Shadcn-vue): + +```vue + + + +``` + +## Testing + +### Vitest (Unit/Integration) + +Tests use Vitest with the backend API running: + +```ts +// test/items.test.ts +import { describe, it, expect } from 'vitest' +import { useUserApi } from '~/composables/use-user-api' + +describe('Items API', () => { + it('should create and fetch item', async () => { + const api = useUserApi() + + // Create item + const { data: created } = await api.items.create({ + name: 'Test Item', + quantity: 1 + }) + + expect(created.name).toBe('Test Item') + + // Fetch item + const { data: fetched } = await api.items.getOne(created.id) + expect(fetched.id).toBe(created.id) + }) +}) +``` + +**Run tests:** +```bash +task ui:watch # Watch mode +cd frontend && pnpm run test:ci # CI mode +``` + +### Playwright (E2E) + +E2E tests in `test/`: + +```ts +// test/e2e/items.spec.ts +import { test, expect } from '@playwright/test' + +test('should create new item', async ({ page }) => { + await page.goto('/items') + + await page.click('button:has-text("New Item")') + await page.fill('input[name="name"]', 'Test Item') + await page.fill('input[name="quantity"]', '5') + await page.click('button:has-text("Save")') + + await expect(page.locator('text=Test Item')).toBeVisible() +}) +``` + +**Run E2E tests:** +```bash +task test:e2e # Full E2E suite +``` + +## Adding a New Feature + +### 1. Update Backend API + +Make backend changes first (schema, service, handler): +```bash +# Edit backend files +task generate # Regenerates TypeScript types +``` + +### 2. Create Component + +Create `components/MyFeature/Card.vue`: +```vue + + + +``` + +### 3. Create Page + +Create `pages/my-feature/[id].vue`: +```vue + + + +``` + +### 4. Test + +```bash +task ui:check # Type checking +task ui:fix # Linting +task ui:watch # Run tests +``` + +## Critical Rules + +1. **Never edit generated types** - `lib/api/types/` is auto-generated, run `task generate` after backend changes +2. **No manual imports for components/composables** - auto-imported from `components/` and `composables/` +3. **Use TypeScript** - all `.vue` files use `