mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-30 17:47:24 +01:00
Compare commits
49 Commits
katos/test
...
copilot/au
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9bc6b4519c | ||
|
|
6f77eae638 | ||
|
|
6926aabd62 | ||
|
|
b735ad12fd | ||
|
|
f3e817e139 | ||
|
|
153ecd1094 | ||
|
|
0be54da9cf | ||
|
|
e4aa38b264 | ||
|
|
e60f005990 | ||
|
|
7dfaa0298b | ||
|
|
fbe7382acd | ||
|
|
1003223b47 | ||
|
|
3c532896f5 | ||
|
|
4ba1a263c8 | ||
|
|
94f0123d9c | ||
|
|
1f6782f8be | ||
|
|
ec8703114f | ||
|
|
5cd7792701 | ||
|
|
d82c52df26 | ||
|
|
033c17552b | ||
|
|
2355438962 | ||
|
|
2a6773d1d6 | ||
|
|
c8c07e2878 | ||
|
|
a3c05c3497 | ||
|
|
ab0647fe68 | ||
|
|
0b616225a6 | ||
|
|
dc9c7b76f2 | ||
|
|
b99102e093 | ||
|
|
3077602f93 | ||
|
|
2bd6ff580a | ||
|
|
35941583c8 | ||
|
|
d576c89c7e | ||
|
|
ff355f3cd8 | ||
|
|
03dc7fa841 | ||
|
|
7aaaa346ab | ||
|
|
27309e61da | ||
|
|
61816acdaa | ||
|
|
c31410727b | ||
|
|
4557df86ed | ||
|
|
b8910f1b21 | ||
|
|
48e4f8da2a | ||
|
|
1e0158c27e | ||
|
|
4fb3ddd661 | ||
|
|
690005de06 | ||
|
|
23da976494 | ||
|
|
aa48c958d7 | ||
|
|
2bd6d0a9e5 | ||
|
|
88275620f2 | ||
|
|
5a058250e6 |
10
.github/ISSUE_TEMPLATE/internal.md
vendored
Normal file
10
.github/ISSUE_TEMPLATE/internal.md
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: "🛠️ Internal / Developer Issue"
|
||||
about: "Unstructured issue for project members only. Outside contributors: please use a standard template."
|
||||
title: "[INT]: "
|
||||
labels: ["internal"]
|
||||
assignees: []
|
||||
---
|
||||
|
||||
**Summary:**
|
||||
[Write here]
|
||||
432
.github/instructions/backend-app-api-handlers.instructions.md
vendored
Normal file
432
.github/instructions/backend-app-api-handlers.instructions.md
vendored
Normal file
@@ -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`
|
||||
341
.github/instructions/backend-internal-core-services.instructions.md
vendored
Normal file
341
.github/instructions/backend-internal-core-services.instructions.md
vendored
Normal file
@@ -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
|
||||
239
.github/instructions/backend-internal-data.instructions.md
vendored
Normal file
239
.github/instructions/backend-internal-data.instructions.md
vendored
Normal file
@@ -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
|
||||
157
.github/instructions/code.instructions.md
vendored
Normal file
157
.github/instructions/code.instructions.md
vendored
Normal file
@@ -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.
|
||||
480
.github/instructions/frontend.instructions.md
vendored
Normal file
480
.github/instructions/frontend.instructions.md
vendored
Normal file
@@ -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
|
||||
<!-- components/Item/Card.vue -->
|
||||
<template>
|
||||
<div class="item-card">{{ item.name }}</div>
|
||||
</template>
|
||||
|
||||
<!-- pages/items.vue - NO import needed -->
|
||||
<template>
|
||||
<ItemCard :item="item" />
|
||||
</template>
|
||||
```
|
||||
|
||||
**Naming convention:** Nested path becomes component name
|
||||
- `components/Item/Card.vue` → `<ItemCard />`
|
||||
- `components/Form/TextField.vue` → `<FormTextField />`
|
||||
|
||||
### 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
|
||||
<!-- pages/item/[id].vue -->
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const id = route.params.id
|
||||
|
||||
const { data: item } = await useUserApi().items.getOne(id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<h1>{{ item.name }}</h1>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 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
|
||||
<script setup lang="ts">
|
||||
import type { ItemCreate, ItemOut } from '~/lib/api/types/data-contracts'
|
||||
|
||||
const api = useUserApi()
|
||||
|
||||
// GET all items
|
||||
const { data: items } = await api.items.getAll({
|
||||
q: 'search term',
|
||||
page: 1,
|
||||
pageSize: 20
|
||||
})
|
||||
|
||||
// GET single item
|
||||
const { data: item } = await api.items.getOne(itemId)
|
||||
|
||||
// POST create item
|
||||
const newItem: ItemCreate = {
|
||||
name: 'New Item',
|
||||
quantity: 1
|
||||
}
|
||||
const { data: created } = await api.items.create(newItem)
|
||||
|
||||
// PUT update item
|
||||
const { data: updated } = await api.items.update(itemId, {
|
||||
quantity: 5
|
||||
})
|
||||
|
||||
// DELETE item
|
||||
await api.items.delete(itemId)
|
||||
</script>
|
||||
```
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Standard Vue 3 Composition API
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { ItemOut } from '~/lib/api/types/data-contracts'
|
||||
|
||||
// Props
|
||||
interface Props {
|
||||
item: ItemOut
|
||||
editable?: boolean
|
||||
}
|
||||
const props = defineProps<Props>()
|
||||
|
||||
// Emits
|
||||
interface Emits {
|
||||
(e: 'update', item: ItemOut): void
|
||||
(e: 'delete', id: string): void
|
||||
}
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// State
|
||||
const isEditing = ref(false)
|
||||
const localItem = ref({ ...props.item })
|
||||
|
||||
// Computed
|
||||
const displayName = computed(() => {
|
||||
return props.item.name.toUpperCase()
|
||||
})
|
||||
|
||||
// Methods
|
||||
function handleSave() {
|
||||
emit('update', localItem.value)
|
||||
isEditing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="item-card">
|
||||
<h3>{{ displayName }}</h3>
|
||||
<p v-if="!isEditing">Quantity: {{ item.quantity }}</p>
|
||||
|
||||
<input
|
||||
v-if="isEditing"
|
||||
v-model.number="localItem.quantity"
|
||||
type="number"
|
||||
/>
|
||||
|
||||
<button v-if="editable" @click="isEditing = !isEditing">
|
||||
{{ isEditing ? 'Cancel' : 'Edit' }}
|
||||
</button>
|
||||
<button v-if="isEditing" @click="handleSave">Save</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.item-card {
|
||||
padding: 1rem;
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
```
|
||||
|
||||
### Using Pinia Stores
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useAuthStore } from '~/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Access state
|
||||
const user = computed(() => authStore.user)
|
||||
const isLoggedIn = computed(() => authStore.isLoggedIn)
|
||||
|
||||
// Call actions
|
||||
async function logout() {
|
||||
await authStore.logout()
|
||||
navigateTo('/login')
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
### Form Handling
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { useForm } from 'vee-validate'
|
||||
import type { ItemCreate } from '~/lib/api/types/data-contracts'
|
||||
|
||||
const api = useUserApi()
|
||||
|
||||
const { values, errors, handleSubmit } = useForm<ItemCreate>({
|
||||
initialValues: {
|
||||
name: '',
|
||||
quantity: 1
|
||||
}
|
||||
})
|
||||
|
||||
const onSubmit = handleSubmit(async (values) => {
|
||||
try {
|
||||
const { data } = await api.items.create(values)
|
||||
navigateTo(`/item/${data.id}`)
|
||||
} catch (error) {
|
||||
console.error('Failed to create item:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<input v-model="values.name" type="text" placeholder="Item name" />
|
||||
<span v-if="errors.name">{{ errors.name }}</span>
|
||||
|
||||
<input v-model.number="values.quantity" type="number" />
|
||||
<span v-if="errors.quantity">{{ errors.quantity }}</span>
|
||||
|
||||
<button type="submit">Create Item</button>
|
||||
</form>
|
||||
</template>
|
||||
```
|
||||
|
||||
## Styling
|
||||
|
||||
### Tailwind CSS
|
||||
|
||||
The project uses Tailwind CSS for styling:
|
||||
|
||||
```vue
|
||||
<template>
|
||||
<div class="flex items-center justify-between p-4 bg-white rounded-lg shadow-md">
|
||||
<h3 class="text-lg font-semibold text-gray-900">{{ item.name }}</h3>
|
||||
<span class="text-sm text-gray-500">Qty: {{ item.quantity }}</span>
|
||||
</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### Shadcn-vue Components
|
||||
|
||||
UI components from `components/ui/` (Shadcn-vue):
|
||||
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardHeader } from '@/components/ui/card'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<h3>{{ item.name }}</h3>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p>{{ item.description }}</p>
|
||||
<Button @click="handleEdit">Edit</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</template>
|
||||
```
|
||||
|
||||
## 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
|
||||
<script setup lang="ts">
|
||||
import type { MyFeatureOut } from '~/lib/api/types/data-contracts'
|
||||
|
||||
interface Props {
|
||||
feature: MyFeatureOut
|
||||
}
|
||||
defineProps<Props>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>{{ feature.name }}</div>
|
||||
</template>
|
||||
```
|
||||
|
||||
### 3. Create Page
|
||||
|
||||
Create `pages/my-feature/[id].vue`:
|
||||
```vue
|
||||
<script setup lang="ts">
|
||||
const route = useRoute()
|
||||
const api = useUserApi()
|
||||
|
||||
const { data: feature } = await api.myFeature.getOne(route.params.id)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<MyFeatureCard :feature="feature" />
|
||||
</template>
|
||||
```
|
||||
|
||||
### 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 `<script setup lang="ts">`
|
||||
4. **Follow file-based routing** - pages in `pages/` become routes automatically
|
||||
5. **Use `useUserApi()` for API calls** - provides typed, authenticated API client
|
||||
6. **Max 1 linting warning in CI** - run `task ui:fix` before committing
|
||||
7. **Test with backend running** - integration tests need API server
|
||||
|
||||
## Common Issues
|
||||
|
||||
- **"Type not found"** → Run `task generate` to regenerate types from backend
|
||||
- **Component not found** → Check naming (nested path = component name)
|
||||
- **API call fails** → Ensure backend is running (`task go:run`)
|
||||
- **Lint errors** → Run `task ui:fix` to auto-fix
|
||||
- **Type errors** → Run `task ui:check` for detailed errors
|
||||
349
.github/scripts/update_language_names.py
vendored
Executable file
349
.github/scripts/update_language_names.py
vendored
Executable file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to automatically update language names in the English translation file.
|
||||
Queries Weblate for translation completion and language names.
|
||||
Only adds languages with >=80% completion to en.json.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
import requests
|
||||
from babel import Locale, UnknownLocaleError
|
||||
|
||||
LOCALES_DIR = Path('frontend/locales')
|
||||
EN_JSON_PATH = LOCALES_DIR / 'en.json'
|
||||
WEBLATE_API_URL = 'https://translate.sysadminsmedia.com/api'
|
||||
WEBLATE_PROJECT = 'homebox'
|
||||
WEBLATE_COMPONENT = 'frontend'
|
||||
COMPLETION_THRESHOLD = 80.0 # Minimum completion percentage to include language
|
||||
TIMEOUT = 10 # seconds
|
||||
|
||||
|
||||
def setup_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s: %(message)s'
|
||||
)
|
||||
|
||||
|
||||
def get_locale_files() -> List[str]:
|
||||
"""Get all locale codes from JSON files in the locales directory."""
|
||||
if not LOCALES_DIR.exists():
|
||||
logging.error("Locales directory not found: %s", LOCALES_DIR)
|
||||
return []
|
||||
|
||||
locale_codes = []
|
||||
for file in sorted(LOCALES_DIR.glob('*.json')):
|
||||
# Extract locale code from filename (e.g., "en.json" -> "en")
|
||||
locale_code = file.stem
|
||||
# Validate locale code format - should not contain dots
|
||||
if '.' not in locale_code:
|
||||
locale_codes.append(locale_code)
|
||||
else:
|
||||
logging.warning("Skipping invalid locale code: %s", locale_code)
|
||||
|
||||
logging.info("Found %d locale files", len(locale_codes))
|
||||
return sorted(locale_codes)
|
||||
|
||||
|
||||
def fetch_weblate_translations() -> Optional[Dict[str, Dict]]:
|
||||
"""
|
||||
Fetch translation statistics from Weblate API.
|
||||
|
||||
Returns:
|
||||
Dict mapping locale code to translation data (percent, name, native_name)
|
||||
or None if API is unavailable
|
||||
"""
|
||||
url = f"{WEBLATE_API_URL}/components/{WEBLATE_PROJECT}/{WEBLATE_COMPONENT}/translations/"
|
||||
|
||||
try:
|
||||
# Weblate API may require pagination
|
||||
translations = {}
|
||||
page_url = url
|
||||
|
||||
while page_url:
|
||||
logging.info("Fetching translations from Weblate: %s", page_url)
|
||||
resp = requests.get(page_url, timeout=TIMEOUT)
|
||||
|
||||
if resp.status_code != 200:
|
||||
logging.warning("Weblate API returned status %d", resp.status_code)
|
||||
return None
|
||||
|
||||
data = resp.json()
|
||||
|
||||
for trans in data.get('results', []):
|
||||
# Weblate uses underscores, we use hyphens
|
||||
locale_code = trans.get('language_code', '').replace('_', '-')
|
||||
percent = trans.get('translated_percent', 0.0)
|
||||
|
||||
lang_info = trans.get('language', {})
|
||||
english_name = lang_info.get('name', '')
|
||||
native_name = lang_info.get('native', '')
|
||||
|
||||
translations[locale_code] = {
|
||||
'percent': percent,
|
||||
'english_name': english_name,
|
||||
'native_name': native_name
|
||||
}
|
||||
|
||||
# Check for next page
|
||||
page_url = data.get('next')
|
||||
|
||||
logging.info("Fetched %d translations from Weblate", len(translations))
|
||||
return translations
|
||||
|
||||
except requests.exceptions.RequestException as e:
|
||||
logging.warning("Failed to fetch from Weblate API: %s", e)
|
||||
return None
|
||||
except Exception as e:
|
||||
logging.error("Unexpected error fetching Weblate data: %s", e)
|
||||
return None
|
||||
|
||||
|
||||
def get_language_name_from_babel(locale_code: str) -> Optional[str]:
|
||||
"""
|
||||
Get the language name using Babel in format "English (Native)".
|
||||
Special handling for variants that need disambiguation (Portuguese, Chinese).
|
||||
|
||||
Args:
|
||||
locale_code: Language/locale code (e.g., 'en', 'pt-BR', 'zh-CN')
|
||||
|
||||
Returns:
|
||||
Language name in format "English (Native)" or None if cannot parse
|
||||
"""
|
||||
try:
|
||||
# Special handling for ar-AA (non-standard code, use standard 'ar')
|
||||
if locale_code == 'ar-AA':
|
||||
locale = Locale.parse('ar')
|
||||
else:
|
||||
# Parse locale code using Babel
|
||||
locale = Locale.parse(locale_code.replace('-', '_'))
|
||||
|
||||
# Get English display name
|
||||
english_name = locale.get_display_name('en')
|
||||
|
||||
# Get native display name
|
||||
native_name = locale.get_display_name(locale)
|
||||
|
||||
if not english_name:
|
||||
return None
|
||||
|
||||
# Special handling for Portuguese variants (distinguish Brazil vs Portugal)
|
||||
if locale_code == 'pt-BR':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Portuguese — Brazil ({native_base})"
|
||||
elif locale_code == 'pt-PT':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Portuguese — Portugal ({native_base})"
|
||||
|
||||
# Special handling for Chinese variants (distinguish Simplified/Traditional and regions)
|
||||
if locale_code == 'zh-CN':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Chinese — Simplified ({native_base})"
|
||||
elif locale_code == 'zh-TW':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Chinese — Traditional ({native_base})"
|
||||
elif locale_code == 'zh-HK':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Chinese — Hong Kong ({native_base})"
|
||||
elif locale_code == 'zh-MO':
|
||||
native_base = native_name.split('(')[0].strip() if '(' in native_name else native_name
|
||||
return f"Chinese — Macau ({native_base})"
|
||||
|
||||
# Format: "English (Native)" if native name differs and is available
|
||||
if native_name and native_name != english_name:
|
||||
# Clean up nested parentheses for complex locales
|
||||
if '(' in english_name and '(' in native_name:
|
||||
# For cases like "Japanese (Japan) (日本語 (日本))"
|
||||
# Simplify to "Japanese (日本語)"
|
||||
english_base = english_name.split('(')[0].strip()
|
||||
native_base = native_name.split('(')[0].strip()
|
||||
return f"{english_base} ({native_base})"
|
||||
else:
|
||||
return f"{english_name} ({native_name})"
|
||||
else:
|
||||
return english_name
|
||||
|
||||
except (UnknownLocaleError, ValueError) as e:
|
||||
logging.debug("Could not parse locale '%s' with Babel: %s", locale_code, e)
|
||||
return None
|
||||
|
||||
|
||||
def get_language_name(locale_code: str, weblate_data: Optional[Dict] = None) -> Optional[str]:
|
||||
"""
|
||||
Get the display name for a locale code.
|
||||
Priority: Weblate API > Babel > None
|
||||
|
||||
Args:
|
||||
locale_code: Language/locale code (e.g., 'en', 'pt-BR', 'zh-CN')
|
||||
weblate_data: Translation data from Weblate (if available)
|
||||
|
||||
Returns:
|
||||
Language name in format "English (Native)" or None if invalid
|
||||
"""
|
||||
# Validate locale code format
|
||||
if '.' in locale_code or locale_code.startswith('languages.'):
|
||||
logging.error("Invalid locale code format: %s", locale_code)
|
||||
return None
|
||||
|
||||
# Try Weblate first
|
||||
if weblate_data and locale_code in weblate_data:
|
||||
english_name = weblate_data[locale_code].get('english_name', '')
|
||||
native_name = weblate_data[locale_code].get('native_name', '')
|
||||
|
||||
if english_name:
|
||||
# Format: "English (Native)" if both names available and different
|
||||
if native_name and native_name != english_name:
|
||||
return f"{english_name} ({native_name})"
|
||||
else:
|
||||
return english_name
|
||||
|
||||
# Fallback to Babel
|
||||
babel_name = get_language_name_from_babel(locale_code)
|
||||
if babel_name:
|
||||
return babel_name
|
||||
|
||||
# If all else fails, return None (don't guess)
|
||||
logging.warning("Could not determine language name for: %s", locale_code)
|
||||
return None
|
||||
|
||||
|
||||
def load_en_json() -> dict:
|
||||
"""Load the English translation JSON file."""
|
||||
if not EN_JSON_PATH.exists():
|
||||
logging.error("English translation file not found: %s", EN_JSON_PATH)
|
||||
return {}
|
||||
|
||||
try:
|
||||
with EN_JSON_PATH.open('r', encoding='utf-8') as f:
|
||||
return json.load(f)
|
||||
except (IOError, json.JSONDecodeError) as e:
|
||||
logging.error("Failed to load %s: %s", EN_JSON_PATH, e)
|
||||
return {}
|
||||
|
||||
|
||||
def save_en_json(data: dict):
|
||||
"""Save the English translation JSON file."""
|
||||
try:
|
||||
with EN_JSON_PATH.open('w', encoding='utf-8') as f:
|
||||
# Use 4-space indentation to match existing file format
|
||||
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||
# Add newline at end of file
|
||||
f.write('\n')
|
||||
logging.info("Saved updated en.json")
|
||||
except IOError as e:
|
||||
logging.error("Failed to save %s: %s", EN_JSON_PATH, e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def update_language_names(en_data: dict, locale_codes: List[str], weblate_data: Optional[Dict] = None) -> bool:
|
||||
"""
|
||||
Update the languages section in en.json.
|
||||
- Add new languages with >=80% completion (from Weblate) or that exist as locale files
|
||||
- Never remove existing entries (even if completion drops below 80%)
|
||||
|
||||
Args:
|
||||
en_data: The parsed en.json data
|
||||
locale_codes: List of all locale codes from files
|
||||
weblate_data: Translation data from Weblate (if available)
|
||||
|
||||
Returns:
|
||||
True if changes were made, False otherwise
|
||||
"""
|
||||
# Ensure languages section exists
|
||||
if 'languages' not in en_data:
|
||||
en_data['languages'] = {}
|
||||
logging.info("Created 'languages' section in en.json")
|
||||
|
||||
languages = en_data['languages']
|
||||
original_languages = languages.copy()
|
||||
|
||||
# Process each locale file
|
||||
added_count = 0
|
||||
skipped_count = 0
|
||||
|
||||
for locale_code in locale_codes:
|
||||
# Skip if already in languages (never remove existing entries)
|
||||
if locale_code in languages:
|
||||
continue
|
||||
|
||||
# Check Weblate completion threshold if data available
|
||||
if weblate_data and locale_code in weblate_data:
|
||||
percent = weblate_data[locale_code].get('percent', 0.0)
|
||||
|
||||
if percent < COMPLETION_THRESHOLD:
|
||||
logging.info("Skipping %s: %.1f%% completion (threshold: %.1f%%)",
|
||||
locale_code, percent, COMPLETION_THRESHOLD)
|
||||
skipped_count += 1
|
||||
continue
|
||||
else:
|
||||
logging.info("Including %s: %.1f%% completion", locale_code, percent)
|
||||
else:
|
||||
# If Weblate data not available, include locale file but log warning
|
||||
logging.info("Including %s: Weblate data not available, locale file exists", locale_code)
|
||||
|
||||
# Get language name
|
||||
language_name = get_language_name(locale_code, weblate_data)
|
||||
|
||||
if language_name:
|
||||
languages[locale_code] = language_name
|
||||
logging.info("Added language: %s = %s", locale_code, language_name)
|
||||
added_count += 1
|
||||
else:
|
||||
logging.warning("Skipping %s: could not determine language name", locale_code)
|
||||
skipped_count += 1
|
||||
|
||||
# Sort languages alphabetically by key
|
||||
en_data['languages'] = dict(sorted(languages.items()))
|
||||
|
||||
# Check if anything changed
|
||||
changed = (original_languages != en_data['languages'])
|
||||
|
||||
if changed:
|
||||
logging.info("Updated %d language names, skipped %d", added_count, skipped_count)
|
||||
else:
|
||||
logging.info("All languages already present, no changes needed")
|
||||
|
||||
return changed
|
||||
|
||||
|
||||
def main():
|
||||
setup_logging()
|
||||
logging.info("🔄 Starting language names update")
|
||||
|
||||
# Get all locale files
|
||||
locale_codes = get_locale_files()
|
||||
if not locale_codes:
|
||||
logging.error("No locale files found")
|
||||
sys.exit(1)
|
||||
|
||||
# Load English translation file
|
||||
en_data = load_en_json()
|
||||
if not en_data:
|
||||
logging.error("Failed to load English translation file")
|
||||
sys.exit(1)
|
||||
|
||||
# Fetch Weblate translation statistics
|
||||
weblate_data = fetch_weblate_translations()
|
||||
if weblate_data:
|
||||
logging.info("Successfully fetched Weblate data for %d languages", len(weblate_data))
|
||||
else:
|
||||
logging.warning("Weblate data not available, proceeding with locale files only")
|
||||
|
||||
# Update language names
|
||||
changed = update_language_names(en_data, locale_codes, weblate_data)
|
||||
|
||||
if changed:
|
||||
save_en_json(en_data)
|
||||
logging.info("✅ Language names updated successfully")
|
||||
else:
|
||||
logging.info("✅ No updates needed, en.json is already up-to-date")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
456
.github/scripts/upgrade-test/create-test-data.sh
vendored
456
.github/scripts/upgrade-test/create-test-data.sh
vendored
@@ -1,6 +1,7 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Script to create test data in HomeBox for upgrade testing
|
||||
# This script creates users, items, attachments, notifiers, locations, and labels
|
||||
|
||||
set -e
|
||||
|
||||
@@ -16,138 +17,397 @@ api_call() {
|
||||
local endpoint=$2
|
||||
local data=$3
|
||||
local token=$4
|
||||
|
||||
local response
|
||||
|
||||
if [ -n "$token" ]; then
|
||||
response=$(curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint")
|
||||
if [ -n "$data" ]; then
|
||||
curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint"
|
||||
else
|
||||
curl -s -X "$method" \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_URL$endpoint"
|
||||
fi
|
||||
else
|
||||
response=$(curl -s -X "$method" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint")
|
||||
if [ -n "$data" ]; then
|
||||
curl -s -X "$method" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$data" \
|
||||
"$API_URL$endpoint"
|
||||
else
|
||||
curl -s -X "$method" \
|
||||
-H "Content-Type: application/json" \
|
||||
"$API_URL$endpoint"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Validate response is proper JSON
|
||||
if ! echo "$response" | jq '.' > /dev/null 2>&1; then
|
||||
echo "Invalid API response for $endpoint: $response" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Function to initialize the test data JSON file
|
||||
initialize_test_data() {
|
||||
echo "Initializing test data JSON file: $TEST_DATA_FILE"
|
||||
if [ -f "$TEST_DATA_FILE" ]; then
|
||||
echo "Removing existing test data file..."
|
||||
rm -f "$TEST_DATA_FILE"
|
||||
fi
|
||||
echo "{\"users\":[],\"locations\":[],\"labels\":[],\"items\":[],\"attachments\":[],\"notifiers\":[]}" > "$TEST_DATA_FILE"
|
||||
}
|
||||
|
||||
# Function to add content to JSON data file
|
||||
add_to_test_data() {
|
||||
local key=$1
|
||||
local value=$2
|
||||
|
||||
jq --argjson data "$value" ".${key} += [\$data]" "$TEST_DATA_FILE" > "${TEST_DATA_FILE}.tmp" && mv "${TEST_DATA_FILE}.tmp" "$TEST_DATA_FILE"
|
||||
}
|
||||
|
||||
# Register a user and get their auth token
|
||||
# Function to register a user and get token
|
||||
register_user() {
|
||||
local email=$1
|
||||
local name=$2
|
||||
local password=$3
|
||||
local group_token=$4
|
||||
|
||||
|
||||
echo "Registering user: $email"
|
||||
|
||||
local payload="{\"email\":\"$email\",\"name\":\"$name\",\"password\":\"$password\""
|
||||
|
||||
if [ -n "$group_token" ]; then
|
||||
payload="$payload,\"groupToken\":\"$group_token\""
|
||||
fi
|
||||
|
||||
payload="$payload}"
|
||||
|
||||
api_call "POST" "/users/register" "$payload"
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$API_URL/users/register")
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Main logic for creating test data
|
||||
initialize_test_data
|
||||
# Function to login and get token
|
||||
login_user() {
|
||||
local email=$1
|
||||
local password=$2
|
||||
|
||||
echo "Logging in user: $email" >&2
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"$email\",\"password\":\"$password\"}" \
|
||||
"$API_URL/users/login")
|
||||
|
||||
echo "$response" | jq -r '.token // empty'
|
||||
}
|
||||
|
||||
# Group 1: Create 5 users
|
||||
echo "=== Creating Group 1 Users ==="
|
||||
group1_user1_response=$(register_user "user1@homebox.test" "User One" "password123")
|
||||
group1_user1_token=$(echo "$group1_user1_response" | jq -r '.token // empty')
|
||||
group1_invite_token=$(echo "$group1_user1_response" | jq -r '.group.inviteToken // empty')
|
||||
# Function to create an item
|
||||
create_item() {
|
||||
local token=$1
|
||||
local name=$2
|
||||
local description=$3
|
||||
local location_id=$4
|
||||
|
||||
echo "Creating item: $name" >&2
|
||||
|
||||
local payload="{\"name\":\"$name\",\"description\":\"$description\""
|
||||
|
||||
if [ -n "$location_id" ]; then
|
||||
payload="$payload,\"locationId\":\"$location_id\""
|
||||
fi
|
||||
|
||||
payload="$payload}"
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$payload" \
|
||||
"$API_URL/items")
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
if [ -z "$group1_user1_token" ]; then
|
||||
echo "Failed to register the first group user" >&2
|
||||
# Function to create a location
|
||||
create_location() {
|
||||
local token=$1
|
||||
local name=$2
|
||||
local description=$3
|
||||
|
||||
echo "Creating location: $name" >&2
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$name\",\"description\":\"$description\"}" \
|
||||
"$API_URL/locations")
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Function to create a label
|
||||
create_label() {
|
||||
local token=$1
|
||||
local name=$2
|
||||
local description=$3
|
||||
|
||||
echo "Creating label: $name" >&2
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$name\",\"description\":\"$description\"}" \
|
||||
"$API_URL/labels")
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Function to create a notifier
|
||||
create_notifier() {
|
||||
local token=$1
|
||||
local name=$2
|
||||
local url=$3
|
||||
|
||||
echo "Creating notifier: $name" >&2
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"name\":\"$name\",\"url\":\"$url\",\"isActive\":true}" \
|
||||
"$API_URL/groups/notifiers")
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Function to attach a file to an item (creates a dummy attachment)
|
||||
attach_file_to_item() {
|
||||
local token=$1
|
||||
local item_id=$2
|
||||
local filename=$3
|
||||
|
||||
echo "Creating attachment for item: $item_id" >&2
|
||||
|
||||
# Create a temporary file with some content
|
||||
local temp_file=$(mktemp)
|
||||
echo "This is a test attachment for $filename" > "$temp_file"
|
||||
|
||||
local response=$(curl -s -X POST \
|
||||
-H "Authorization: Bearer $token" \
|
||||
-F "file=@$temp_file" \
|
||||
-F "type=attachment" \
|
||||
-F "name=$filename" \
|
||||
"$API_URL/items/$item_id/attachments")
|
||||
|
||||
rm -f "$temp_file"
|
||||
|
||||
echo "$response"
|
||||
}
|
||||
|
||||
# Initialize test data storage
|
||||
echo "{\"users\":[]}" > "$TEST_DATA_FILE"
|
||||
|
||||
echo "=== Step 1: Create first group with 5 users ==="
|
||||
|
||||
# Register first user (creates a new group)
|
||||
user1_response=$(register_user "user1@homebox.test" "User One" "TestPassword123!")
|
||||
user1_token=$(echo "$user1_response" | jq -r '.token // empty')
|
||||
group_token=$(echo "$user1_response" | jq -r '.group.inviteToken // empty')
|
||||
|
||||
if [ -z "$user1_token" ]; then
|
||||
echo "Failed to register first user"
|
||||
echo "Response: $user1_response"
|
||||
exit 1
|
||||
fi
|
||||
add_to_test_data "users" "{\"email\": \"user1@homebox.test\", \"token\": \"$group1_user1_token\", \"group\": 1}"
|
||||
|
||||
# Add 4 more users to the same group
|
||||
for user in 2 3 4 5; do
|
||||
response=$(register_user "user$user@homebox.test" "User $user" "password123" "$group1_invite_token")
|
||||
token=$(echo "$response" | jq -r '.token // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user$user@homebox.test\", \"token\": \"$token\", \"group\": 1}"
|
||||
echo "First user registered with token. Group token: $group_token"
|
||||
|
||||
# Store user1 data
|
||||
jq --arg email "user1@homebox.test" \
|
||||
--arg password "TestPassword123!" \
|
||||
--arg token "$user1_token" \
|
||||
--arg group "1" \
|
||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
# Register 4 more users in the same group
|
||||
for i in {2..5}; do
|
||||
echo "Registering user$i in group 1..."
|
||||
user_response=$(register_user "user${i}@homebox.test" "User $i" "TestPassword123!" "$group_token")
|
||||
user_token=$(echo "$user_response" | jq -r '.token // empty')
|
||||
|
||||
if [ -z "$user_token" ]; then
|
||||
echo "Failed to register user$i"
|
||||
echo "Response: $user_response"
|
||||
else
|
||||
echo "user$i registered successfully"
|
||||
# Store user data
|
||||
jq --arg email "user${i}@homebox.test" \
|
||||
--arg password "TestPassword123!" \
|
||||
--arg token "$user_token" \
|
||||
--arg group "1" \
|
||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
fi
|
||||
done
|
||||
|
||||
# Group 2: Create 2 users
|
||||
echo "=== Creating Group 2 Users ==="
|
||||
group2_user1_response=$(register_user "user6@homebox.test" "User Six" "password123")
|
||||
group2_user1_token=$(echo "$group2_user1_response" | jq -r '.token // empty')
|
||||
group2_invite_token=$(echo "$group2_user1_response" | jq -r '.group.inviteToken // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user6@homebox.test\", \"token\": \"$group2_user1_token\", \"group\": 2}"
|
||||
echo "=== Step 2: Create second group with 2 users ==="
|
||||
|
||||
response=$(register_user "user7@homebox.test" "User Seven" "password123" "$group2_invite_token")
|
||||
group2_user2_token=$(echo "$response" | jq -r '.token // empty')
|
||||
add_to_test_data "users" "{\"email\": \"user7@homebox.test\", \"token\": \"$group2_user2_token\", \"group\": 2}"
|
||||
# Register first user of second group
|
||||
user6_response=$(register_user "user6@homebox.test" "User Six" "TestPassword123!")
|
||||
user6_token=$(echo "$user6_response" | jq -r '.token // empty')
|
||||
group2_token=$(echo "$user6_response" | jq -r '.group.inviteToken // empty')
|
||||
|
||||
# Create Locations
|
||||
echo "=== Creating Locations ==="
|
||||
group1_locations=()
|
||||
group1_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Living Room\", \"description\": \"Family area\" }" "$group1_user1_token")")
|
||||
group1_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Garage\", \"description\": \"Storage area\" }" "$group1_user1_token")")
|
||||
group2_locations=()
|
||||
group2_locations+=("$(api_call "POST" "/locations" "{ \"name\": \"Office\", \"description\": \"Workspace\" }" "$group2_user1_token")")
|
||||
if [ -z "$user6_token" ]; then
|
||||
echo "Failed to register user6"
|
||||
echo "Response: $user6_response"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Add Locations to Test Data
|
||||
for loc in "${group1_locations[@]}"; do
|
||||
loc_id=$(echo "$loc" | jq -r '.id // empty')
|
||||
add_to_test_data "locations" "{\"id\": \"$loc_id\", \"group\": 1}"
|
||||
done
|
||||
echo "user6 registered with token. Group 2 token: $group2_token"
|
||||
|
||||
for loc in "${group2_locations[@]}"; do
|
||||
loc_id=$(echo "$loc" | jq -r '.id // empty')
|
||||
add_to_test_data "locations" "{\"id\": \"$loc_id\", \"group\": 2}"
|
||||
done
|
||||
# Store user6 data
|
||||
jq --arg email "user6@homebox.test" \
|
||||
--arg password "TestPassword123!" \
|
||||
--arg token "$user6_token" \
|
||||
--arg group "2" \
|
||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
# Create Labels
|
||||
echo "=== Creating Labels ==="
|
||||
label1=$(api_call "POST" "/labels" "{ \"name\": \"Electronics\", \"description\": \"Devices\" }" "$group1_user1_token")
|
||||
add_to_test_data "labels" "$label1"
|
||||
# Register second user in group 2
|
||||
user7_response=$(register_user "user7@homebox.test" "User Seven" "TestPassword123!" "$group2_token")
|
||||
user7_token=$(echo "$user7_response" | jq -r '.token // empty')
|
||||
|
||||
label2=$(api_call "POST" "/labels" "{ \"name\": \"Important\", \"description\": \"High Priority\" }" "$group1_user1_token")
|
||||
add_to_test_data "labels" "$label2"
|
||||
if [ -z "$user7_token" ]; then
|
||||
echo "Failed to register user7"
|
||||
echo "Response: $user7_response"
|
||||
else
|
||||
echo "user7 registered successfully"
|
||||
# Store user7 data
|
||||
jq --arg email "user7@homebox.test" \
|
||||
--arg password "TestPassword123!" \
|
||||
--arg token "$user7_token" \
|
||||
--arg group "2" \
|
||||
'.users += [{"email":$email,"password":$password,"token":$token,"group":$group}]' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
fi
|
||||
|
||||
# Create Items and Attachments
|
||||
echo "=== Creating Items and Attachments ==="
|
||||
item1=$(api_call "POST" "/items" "{ \"name\": \"Laptop\", \"description\": \"Work laptop\", \"locationId\": \"$(echo ${group1_locations[0]} | jq -r '.id // empty')\" }" "$group1_user1_token")
|
||||
echo "=== Step 3: Create locations for each group ==="
|
||||
|
||||
# Create locations for group 1 (using user1's token)
|
||||
location1=$(create_location "$user1_token" "Living Room" "Main living area")
|
||||
location1_id=$(echo "$location1" | jq -r '.id // empty')
|
||||
echo "Created location: Living Room (ID: $location1_id)"
|
||||
|
||||
location2=$(create_location "$user1_token" "Garage" "Storage and tools")
|
||||
location2_id=$(echo "$location2" | jq -r '.id // empty')
|
||||
echo "Created location: Garage (ID: $location2_id)"
|
||||
|
||||
# Create location for group 2 (using user6's token)
|
||||
location3=$(create_location "$user6_token" "Home Office" "Work from home space")
|
||||
location3_id=$(echo "$location3" | jq -r '.id // empty')
|
||||
echo "Created location: Home Office (ID: $location3_id)"
|
||||
|
||||
# Store locations
|
||||
jq --arg loc1 "$location1_id" \
|
||||
--arg loc2 "$location2_id" \
|
||||
--arg loc3 "$location3_id" \
|
||||
'.locations = {"group1":[$loc1,$loc2],"group2":[$loc3]}' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
echo "=== Step 4: Create labels for each group ==="
|
||||
|
||||
# Create labels for group 1
|
||||
label1=$(create_label "$user1_token" "Electronics" "Electronic devices")
|
||||
label1_id=$(echo "$label1" | jq -r '.id // empty')
|
||||
echo "Created label: Electronics (ID: $label1_id)"
|
||||
|
||||
label2=$(create_label "$user1_token" "Important" "High priority items")
|
||||
label2_id=$(echo "$label2" | jq -r '.id // empty')
|
||||
echo "Created label: Important (ID: $label2_id)"
|
||||
|
||||
# Create label for group 2
|
||||
label3=$(create_label "$user6_token" "Work Equipment" "Items for work")
|
||||
label3_id=$(echo "$label3" | jq -r '.id // empty')
|
||||
echo "Created label: Work Equipment (ID: $label3_id)"
|
||||
|
||||
# Store labels
|
||||
jq --arg lab1 "$label1_id" \
|
||||
--arg lab2 "$label2_id" \
|
||||
--arg lab3 "$label3_id" \
|
||||
'.labels = {"group1":[$lab1,$lab2],"group2":[$lab3]}' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
echo "=== Step 5: Create test notifier ==="
|
||||
|
||||
# Create notifier for group 1
|
||||
notifier1=$(create_notifier "$user1_token" "TESTING" "https://example.com/webhook")
|
||||
notifier1_id=$(echo "$notifier1" | jq -r '.id // empty')
|
||||
echo "Created notifier: TESTING (ID: $notifier1_id)"
|
||||
|
||||
# Store notifier
|
||||
jq --arg not1 "$notifier1_id" \
|
||||
'.notifiers = {"group1":[$not1]}' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
echo "=== Step 6: Create items for all users ==="
|
||||
|
||||
# Create items for users in group 1
|
||||
declare -A user_tokens
|
||||
user_tokens[1]=$user1_token
|
||||
user_tokens[2]=$(echo "$user1_token") # Users in same group share data, but we'll use user1 token
|
||||
user_tokens[3]=$(echo "$user1_token")
|
||||
user_tokens[4]=$(echo "$user1_token")
|
||||
user_tokens[5]=$(echo "$user1_token")
|
||||
|
||||
# Items for group 1 users
|
||||
echo "Creating items for group 1..."
|
||||
item1=$(create_item "$user1_token" "Laptop Computer" "Dell XPS 15 for work" "$location1_id")
|
||||
item1_id=$(echo "$item1" | jq -r '.id // empty')
|
||||
add_to_test_data "items" "{\"id\": \"$item1_id\", \"group\": 1}"
|
||||
echo "Created item: Laptop Computer (ID: $item1_id)"
|
||||
|
||||
attachment1=$(api_call "POST" "/items/$item1_id/attachments" "" "$group1_user1_token")
|
||||
add_to_test_data "attachments" "{\"id\": \"$(echo $attachment1 | jq -r '.id // empty')\", \"itemId\": \"$item1_id\"}"
|
||||
item2=$(create_item "$user1_token" "Power Drill" "DeWalt 20V cordless drill" "$location2_id")
|
||||
item2_id=$(echo "$item2" | jq -r '.id // empty')
|
||||
echo "Created item: Power Drill (ID: $item2_id)"
|
||||
|
||||
# Create Test Notifier
|
||||
echo "=== Creating Notifiers ==="
|
||||
notifier=$(api_call "POST" "/notifiers" "{ \"name\": \"TESTING\", \"url\": \"https://example.com/webhook\", \"isActive\": true }" "$group1_user1_token")
|
||||
add_to_test_data "notifiers" "$notifier"
|
||||
item3=$(create_item "$user1_token" "TV Remote" "Samsung TV remote control" "$location1_id")
|
||||
item3_id=$(echo "$item3" | jq -r '.id // empty')
|
||||
echo "Created item: TV Remote (ID: $item3_id)"
|
||||
|
||||
item4=$(create_item "$user1_token" "Tool Box" "Red metal tool box with tools" "$location2_id")
|
||||
item4_id=$(echo "$item4" | jq -r '.id // empty')
|
||||
echo "Created item: Tool Box (ID: $item4_id)"
|
||||
|
||||
item5=$(create_item "$user1_token" "Coffee Maker" "Breville espresso machine" "$location1_id")
|
||||
item5_id=$(echo "$item5" | jq -r '.id // empty')
|
||||
echo "Created item: Coffee Maker (ID: $item5_id)"
|
||||
|
||||
# Items for group 2 users
|
||||
echo "Creating items for group 2..."
|
||||
item6=$(create_item "$user6_token" "Monitor" "27 inch 4K monitor" "$location3_id")
|
||||
item6_id=$(echo "$item6" | jq -r '.id // empty')
|
||||
echo "Created item: Monitor (ID: $item6_id)"
|
||||
|
||||
item7=$(create_item "$user6_token" "Keyboard" "Mechanical keyboard" "$location3_id")
|
||||
item7_id=$(echo "$item7" | jq -r '.id // empty')
|
||||
echo "Created item: Keyboard (ID: $item7_id)"
|
||||
|
||||
# Store items
|
||||
jq --argjson group1_items "[\"$item1_id\",\"$item2_id\",\"$item3_id\",\"$item4_id\",\"$item5_id\"]" \
|
||||
--argjson group2_items "[\"$item6_id\",\"$item7_id\"]" \
|
||||
'.items = {"group1":$group1_items,"group2":$group2_items}' \
|
||||
"$TEST_DATA_FILE" > "$TEST_DATA_FILE.tmp" && mv "$TEST_DATA_FILE.tmp" "$TEST_DATA_FILE"
|
||||
|
||||
echo "=== Step 7: Add attachments to items ==="
|
||||
|
||||
# Add attachments for group 1 items
|
||||
echo "Adding attachments to group 1 items..."
|
||||
attach_file_to_item "$user1_token" "$item1_id" "laptop-receipt.pdf"
|
||||
attach_file_to_item "$user1_token" "$item1_id" "laptop-warranty.pdf"
|
||||
attach_file_to_item "$user1_token" "$item2_id" "drill-manual.pdf"
|
||||
attach_file_to_item "$user1_token" "$item3_id" "remote-guide.pdf"
|
||||
attach_file_to_item "$user1_token" "$item4_id" "toolbox-inventory.txt"
|
||||
|
||||
# Add attachments for group 2 items
|
||||
echo "Adding attachments to group 2 items..."
|
||||
attach_file_to_item "$user6_token" "$item6_id" "monitor-receipt.pdf"
|
||||
attach_file_to_item "$user6_token" "$item7_id" "keyboard-manual.pdf"
|
||||
|
||||
echo "=== Test Data Creation Complete ==="
|
||||
cat "$TEST_DATA_FILE" | jq
|
||||
echo "Test data file saved to: $TEST_DATA_FILE"
|
||||
echo "Summary:"
|
||||
echo " - Users created: 7 (5 in group 1, 2 in group 2)"
|
||||
echo " - Locations created: 3"
|
||||
echo " - Labels created: 3"
|
||||
echo " - Notifiers created: 1"
|
||||
echo " - Items created: 7"
|
||||
echo " - Attachments created: 7"
|
||||
|
||||
# Display the test data file for verification
|
||||
echo ""
|
||||
echo "Test data:"
|
||||
cat "$TEST_DATA_FILE" | jq '.'
|
||||
|
||||
exit 0
|
||||
|
||||
16
.github/workflows/binaries-publish.yaml
vendored
16
.github/workflows/binaries-publish.yaml
vendored
@@ -17,19 +17,17 @@ jobs:
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
with:
|
||||
version: 9.15.3
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Build Frontend and Copy to Backend
|
||||
working-directory: frontend
|
||||
@@ -51,7 +49,7 @@ jobs:
|
||||
- name: Run GoReleaser
|
||||
id: releaser
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
||||
with:
|
||||
workdir: "backend"
|
||||
distribution: goreleaser
|
||||
@@ -75,7 +73,7 @@ jobs:
|
||||
|
||||
- name: Run GoReleaser No Release
|
||||
if: ${{ !startsWith(github.ref, 'refs/tags/') }}
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@e435ccd777264be153ace6237001ef4d979d3a7a
|
||||
with:
|
||||
workdir: "backend"
|
||||
distribution: goreleaser
|
||||
@@ -93,7 +91,7 @@ jobs:
|
||||
actions: read # To read the workflow path.
|
||||
id-token: write # To sign the provenance.
|
||||
contents: write # To add assets to a release.
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v1.9.0
|
||||
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@f7dd8c54c2067bafc12ca7a55595d5ee9b75204a
|
||||
with:
|
||||
base64-subjects: "${{ needs.goreleaser.outputs.hashes }}"
|
||||
upload-assets: true # upload to a new release
|
||||
@@ -105,7 +103,7 @@ jobs:
|
||||
permissions: read-all
|
||||
steps:
|
||||
- name: Install the verifier
|
||||
uses: slsa-framework/slsa-verifier/actions/installer@v2.4.0
|
||||
uses: slsa-framework/slsa-verifier/actions/installer@ea584f4502babc6f60d9bc799dbbb13c1caa9ee6
|
||||
|
||||
- name: Download assets
|
||||
env:
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4
|
||||
with:
|
||||
dry-run: true
|
||||
delete-ghost-images: true
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- uses: dataaxiom/ghcr-cleanup-action@v1
|
||||
- uses: dataaxiom/ghcr-cleanup-action@cd0cdb900b5dbf3a6f2cc869f0dbb0b8211f50c4
|
||||
with:
|
||||
dry-run: false
|
||||
delete-untagged: true
|
||||
|
||||
14
.github/workflows/copilot-setup-steps.yml
vendored
14
.github/workflows/copilot-setup-steps.yml
vendored
@@ -26,25 +26,23 @@ jobs:
|
||||
# If you do not check out your code, Copilot will do this for you.
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
||||
with:
|
||||
node-version: "22"
|
||||
node-version: "24"
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
|
||||
19
.github/workflows/docker-publish-hardened.yaml
vendored
19
.github/workflows/docker-publish-hardened.yaml
vendored
@@ -33,7 +33,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -43,10 +43,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/v7
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Enable Debug Logs
|
||||
@@ -56,7 +57,7 @@ jobs:
|
||||
ACTIONS_STEP_DEBUG: true
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
@@ -123,7 +124,7 @@ jobs:
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
|
||||
- name: Attest platform-specific images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -216,7 +217,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest GHCR images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -240,7 +241,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest Dockerhub images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
|
||||
43
.github/workflows/docker-publish-rootless.yaml
vendored
43
.github/workflows/docker-publish-rootless.yaml
vendored
@@ -37,7 +37,7 @@ env:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
@@ -47,10 +47,11 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/v7
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Enable Debug Logs
|
||||
@@ -60,7 +61,7 @@ jobs:
|
||||
ACTIONS_STEP_DEBUG: true
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
@@ -75,40 +76,40 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: |
|
||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||
name=${{ env.GHCR_REPO }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
||||
with:
|
||||
image: ghcr.io/sysadminsmedia/binfmt:latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
with:
|
||||
driver-opts: |
|
||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
|
||||
with:
|
||||
context: . # Explicitly specify the build context
|
||||
file: ./Dockerfile.rootless # Explicitly specify the Dockerfile
|
||||
@@ -125,7 +126,7 @@ jobs:
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
|
||||
- name: Attest platform-specific images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -139,7 +140,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -159,35 +160,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
with:
|
||||
driver-opts: |
|
||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: |
|
||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||
@@ -218,7 +219,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest GHCR images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -242,7 +243,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest Dockerhub images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
|
||||
43
.github/workflows/docker-publish.yaml
vendored
43
.github/workflows/docker-publish.yaml
vendored
@@ -37,7 +37,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ matrix.runner }}
|
||||
permissions:
|
||||
contents: read # Allows access to repository contents (read-only)
|
||||
packages: write # Allows pushing to GHCR
|
||||
@@ -47,14 +47,15 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
platform:
|
||||
- linux/amd64
|
||||
- linux/arm64
|
||||
- linux/arm/v7
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
|
||||
- name: Prepare
|
||||
run: |
|
||||
@@ -70,40 +71,40 @@ jobs:
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: |
|
||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||
name=${{ env.GHCR_REPO }}
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
uses: docker/setup-qemu-action@29109295f81e9208d7d86ff1c6c12d2833863392
|
||||
with:
|
||||
image: ghcr.io/sysadminsmedia/binfmt:latest
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
with:
|
||||
driver-opts: |
|
||||
image=ghcr.io/sysadminsmedia/buildkit:latest
|
||||
|
||||
- name: Build and push by digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v6
|
||||
uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83
|
||||
with:
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
@@ -118,7 +119,7 @@ jobs:
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
|
||||
- name: Attest platform-specific images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -132,7 +133,7 @@ jobs:
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: digests-${{ env.PLATFORM_PAIR }}
|
||||
path: /tmp/digests/*
|
||||
@@ -152,35 +153,35 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: /tmp/digests
|
||||
pattern: digests-*
|
||||
merge-multiple: true
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
|
||||
- name: Login to GHCR
|
||||
uses: docker/login-action@v3
|
||||
uses: docker/login-action@184bdaa0721073962dff0199f1fb9940f07167d1
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
uses: docker/setup-buildx-action@e468171a9de216ec08956ac3ada2f0791b6bd435
|
||||
with:
|
||||
driver-opts: |
|
||||
image=ghcr.io/sysadminsmedia/buildkit:master
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
uses: docker/metadata-action@c1e51972afc2121e065aed6d45c65596fe445f3f
|
||||
with:
|
||||
images: |
|
||||
name=${{ env.DOCKERHUB_REPO }},enable=${{ github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/') }}
|
||||
@@ -209,7 +210,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest GHCR images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: github.event_name != 'pull_request'
|
||||
with:
|
||||
subject-name: ${{ env.GHCR_REPO }}
|
||||
@@ -233,7 +234,7 @@ jobs:
|
||||
echo "digest=$digest" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Attest Dockerhub images
|
||||
uses: actions/attest-build-provenance@v1
|
||||
uses: actions/attest-build-provenance@00014ed6ed5efc5b1ab7f7f34a39eb55d41aa4f8
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
subject-name: docker.io/${{ env.DOCKERHUB_REPO }}
|
||||
|
||||
34
.github/workflows/e2e-partial.yaml
vendored
34
.github/workflows/e2e-partial.yaml
vendored
@@ -1,5 +1,11 @@
|
||||
name: E2E (Playwright)
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -15,28 +21,26 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -49,7 +53,7 @@ jobs:
|
||||
- name: Run E2E Tests
|
||||
run: task test:e2e -- --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }}
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
name: Upload partial Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
with:
|
||||
@@ -64,20 +68,18 @@ jobs:
|
||||
name: Merge Playwright Reports
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
||||
with:
|
||||
node-version: lts/*
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
working-directory: frontend
|
||||
|
||||
- name: Download blob reports from GitHub Actions Artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093
|
||||
with:
|
||||
path: frontend/all-blob-reports
|
||||
pattern: blob-report-*
|
||||
@@ -88,7 +90,7 @@ jobs:
|
||||
working-directory: frontend
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@v4
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02
|
||||
with:
|
||||
name: html-report--attempt-${{ github.run_attempt }}
|
||||
path: frontend/playwright-report
|
||||
|
||||
50
.github/workflows/issue-gatekeeper.yml
vendored
Normal file
50
.github/workflows/issue-gatekeeper.yml
vendored
Normal file
@@ -0,0 +1,50 @@
|
||||
name: Issue Gatekeeper
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [ opened ]
|
||||
|
||||
jobs:
|
||||
check-permissions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Verify Internal Template Use
|
||||
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
|
||||
with:
|
||||
script: |
|
||||
const { owner, repo } = context.repo;
|
||||
const issue_number = context.issue.number;
|
||||
const actor = context.payload.sender.login;
|
||||
|
||||
// 1. Get user permission level
|
||||
const { data: perms } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner,
|
||||
repo,
|
||||
username: actor
|
||||
});
|
||||
|
||||
const isMember = ['admin', 'write'].includes(perms.permission);
|
||||
const body = context.payload.issue.body || "";
|
||||
|
||||
// 2. Check if they used the internal template (or if the issue is blank)
|
||||
// We detect this by checking for our specific template string or the 'internal' label
|
||||
const usedInternal = context.payload.issue.labels.some(l => l.name === 'internal');
|
||||
|
||||
if (usedInternal && !isMember) {
|
||||
await github.rest.issues.createComment({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
body: `@${actor}, the "Internal" template is restricted to project members. Please use one of the standard bug or feature templates for this repository.`
|
||||
});
|
||||
|
||||
await github.rest.issues.update({
|
||||
owner,
|
||||
repo,
|
||||
issue_number,
|
||||
state: 'closed'
|
||||
});
|
||||
}
|
||||
14
.github/workflows/partial-backend.yaml
vendored
14
.github/workflows/partial-backend.yaml
vendored
@@ -1,5 +1,11 @@
|
||||
name: Go Build/Test
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -7,21 +13,21 @@ jobs:
|
||||
Go:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: golangci-lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
uses: golangci/golangci-lint-action@1e7e51e771db61008b38414a730f564565cf7c20
|
||||
with:
|
||||
# Optional: version of golangci-lint to use in form of v1.2 or v1.2.3 or `latest` to use the latest version
|
||||
version: latest
|
||||
|
||||
42
.github/workflows/partial-frontend.yaml
vendored
42
.github/workflows/partial-frontend.yaml
vendored
@@ -1,5 +1,11 @@
|
||||
name: Frontend
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
@@ -9,13 +15,11 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -48,28 +52,26 @@ jobs:
|
||||
--health-retries 5
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: lts/*
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
@@ -99,28 +101,26 @@ jobs:
|
||||
- 5432:5432
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
uses: arduino/setup-task@b91d5d2c96a56797b48ac1e0e89220bf64044611
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
uses: actions/setup-go@4dc6199c7b1a012772edbd06daecab0f50c9053c
|
||||
with:
|
||||
go-version: "1.23"
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
- uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
- uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install
|
||||
|
||||
6
.github/workflows/pull-requests.yaml
vendored
6
.github/workflows/pull-requests.yaml
vendored
@@ -1,5 +1,11 @@
|
||||
name: Pull Request CI
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
checks: write
|
||||
pull-requests: write
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches:
|
||||
|
||||
6
.github/workflows/update-currencies.yml
vendored
6
.github/workflows/update-currencies.yml
vendored
@@ -15,12 +15,12 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
|
||||
with:
|
||||
python-version: '3.8'
|
||||
cache: 'pip'
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: update-currencies
|
||||
|
||||
70
.github/workflows/update-language-names.yml
vendored
Normal file
70
.github/workflows/update-language-names.yml
vendored
Normal file
@@ -0,0 +1,70 @@
|
||||
name: Update Language Names
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'frontend/locales/*.json'
|
||||
- '.github/scripts/update_language_names.py'
|
||||
- '.github/workflows/update-language-names.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
update-language-names:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
|
||||
with:
|
||||
python-version: '3.8'
|
||||
cache: 'pip'
|
||||
cache-dependency-path: .github/workflows/update-languages/requirements.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r .github/workflows/update-languages/requirements.txt
|
||||
|
||||
- name: Run language names update script
|
||||
run: python .github/scripts/update_language_names.py
|
||||
|
||||
- name: Check for en.json changes
|
||||
run: |
|
||||
if git diff --quiet -- frontend/locales/en.json; then
|
||||
echo "changed=false" >> $GITHUB_ENV
|
||||
else
|
||||
echo "changed=true" >> $GITHUB_ENV
|
||||
fi
|
||||
|
||||
- name: Create Pull Request
|
||||
if: env.changed == 'true'
|
||||
uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
branch: automation/update-language-file
|
||||
base: main
|
||||
title: "Update language names in en.json"
|
||||
commit-message: "chore: update language names in en.json"
|
||||
body: |
|
||||
This PR automatically updates the language names in `frontend/locales/en.json` based on the available locale files.
|
||||
|
||||
New languages have been added to ensure all locale files have corresponding language names in the English translation file.
|
||||
|
||||
🤖 This PR was automatically created by the update-language-names workflow.
|
||||
path: .
|
||||
add-paths: |
|
||||
frontend/locales/en.json
|
||||
|
||||
- name: No updates needed
|
||||
if: env.changed == 'false'
|
||||
run: echo "✅ en.json language names are already up-to-date"
|
||||
2
.github/workflows/update-languages/requirements.txt
vendored
Normal file
2
.github/workflows/update-languages/requirements.txt
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
babel
|
||||
requests
|
||||
111
.github/workflows/upgrade-test.yaml
vendored
111
.github/workflows/upgrade-test.yaml
vendored
@@ -1,16 +1,16 @@
|
||||
name: HomeBox Upgrade Test
|
||||
#name: HomeBox Upgrade Test
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# on:
|
||||
# schedule:
|
||||
# Run daily at 2 AM UTC
|
||||
- cron: '0 2 * * *'
|
||||
workflow_dispatch: # Allow manual trigger
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- '.github/workflows/upgrade-test.yaml'
|
||||
- '.github/scripts/upgrade-test/**'
|
||||
# - cron: '0 2 * * *'
|
||||
# workflow_dispatch: # Allow manual trigger
|
||||
# push:
|
||||
# branches:
|
||||
# - main
|
||||
# paths:
|
||||
# - '.github/workflows/upgrade-test.yaml'
|
||||
# - '.github/scripts/upgrade-test/**'
|
||||
|
||||
jobs:
|
||||
upgrade-test:
|
||||
@@ -18,51 +18,46 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 60
|
||||
permissions:
|
||||
contents: read
|
||||
packages: read
|
||||
|
||||
contents: read # Read repository contents
|
||||
packages: read # Pull Docker images from GHCR
|
||||
|
||||
steps:
|
||||
# Step 1: Checkout repository
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
# Step 2: Setup dependencies (Node.js, Docker, pnpm, and Playwright)
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: lts/*
|
||||
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
|
||||
|
||||
- name: Install Playwright
|
||||
run: |
|
||||
cd frontend
|
||||
pnpm install
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
|
||||
# Step 3: Prepare environment and /tmp directories
|
||||
- name: Create test data directories
|
||||
|
||||
- name: Create test data directory
|
||||
run: |
|
||||
mkdir -p /tmp/homebox-data-old
|
||||
mkdir -p /tmp/homebox-data-new
|
||||
chmod -R 777 /tmp/homebox-data-old
|
||||
chmod -R 777 /tmp/homebox-data-new
|
||||
echo "Directories created:"
|
||||
ls -la /tmp/
|
||||
|
||||
# Step 4: Pull and start the stable HomeBox image
|
||||
|
||||
# Step 1: Pull and deploy latest stable version
|
||||
- name: Pull latest stable HomeBox image
|
||||
run: |
|
||||
docker pull ghcr.io/sysadminsmedia/homebox:latest
|
||||
|
||||
|
||||
- name: Start HomeBox (stable version)
|
||||
run: |
|
||||
docker run -d \
|
||||
@@ -78,33 +73,33 @@ jobs:
|
||||
# Wait for the service to be ready
|
||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
||||
echo "HomeBox stable version is ready"
|
||||
|
||||
# Step 5: Run test data script
|
||||
- name: Create test data (users, items, locations, labels)
|
||||
|
||||
# Step 2: Create test data
|
||||
- name: Create test data
|
||||
run: |
|
||||
chmod +x .github/scripts/upgrade-test/create-test-data.sh
|
||||
.github/scripts/upgrade-test/create-test-data.sh
|
||||
env:
|
||||
HOMEBOX_URL: http://localhost:7745
|
||||
|
||||
- name: Validate test data creation
|
||||
|
||||
- name: Verify initial data creation
|
||||
run: |
|
||||
echo "Verifying test data was created..."
|
||||
# Check test-users.json content
|
||||
cat /tmp/test-users.json | jq || echo "Test-users file is empty or malformed!"
|
||||
# Check the database file exists
|
||||
if [ ! -f /tmp/homebox-data-old/homebox.db ]; then
|
||||
echo "No database found in the old instance directory!" && exit 1
|
||||
# Check if database file exists and has content
|
||||
if [ -f /tmp/homebox-data-old/homebox.db ]; then
|
||||
ls -lh /tmp/homebox-data-old/homebox.db
|
||||
echo "Database file exists"
|
||||
else
|
||||
echo "Database file not found!"
|
||||
exit 1
|
||||
fi
|
||||
echo "Test data creation validated successfully!"
|
||||
|
||||
# Step 6: Stop the HomeBox stable instance
|
||||
|
||||
- name: Stop old HomeBox instance
|
||||
run: |
|
||||
docker stop homebox-old
|
||||
docker rm homebox-old
|
||||
|
||||
# Step 7: Build HomeBox from the main branch
|
||||
|
||||
# Step 3: Build latest version from main branch
|
||||
- name: Build HomeBox from main branch
|
||||
run: |
|
||||
docker build \
|
||||
@@ -114,13 +109,13 @@ jobs:
|
||||
-t homebox:test \
|
||||
-f Dockerfile \
|
||||
.
|
||||
|
||||
# Step 8: Start the new HomeBox version with migrated data
|
||||
|
||||
# Step 4: Copy data and start new version
|
||||
- name: Copy data to new location
|
||||
run: |
|
||||
cp -r /tmp/homebox-data-old/* /tmp/homebox-data-new/
|
||||
chmod -R 777 /tmp/homebox-data-new
|
||||
|
||||
|
||||
- name: Start HomeBox (new version)
|
||||
run: |
|
||||
docker run -d \
|
||||
@@ -133,23 +128,23 @@ jobs:
|
||||
-v /tmp/homebox-data-new:/data \
|
||||
homebox:test
|
||||
|
||||
# Wait for the updated service to be ready
|
||||
# Wait for the service to be ready
|
||||
timeout 60 bash -c 'until curl -f http://localhost:7745/api/v1/status; do sleep 2; done'
|
||||
echo "HomeBox new version is ready"
|
||||
|
||||
# Step 9: Execute Playwright verification tests
|
||||
- name: Run Playwright verification tests
|
||||
|
||||
# Step 5: Run verification tests with Playwright
|
||||
- name: Run verification tests
|
||||
run: |
|
||||
cd frontend
|
||||
TEST_DATA_FILE=/tmp/test-users.json \
|
||||
E2E_BASE_URL=http://localhost:7745 \
|
||||
pnpm exec playwright test \
|
||||
-c ./test/playwright.config.ts \
|
||||
--project=chromium \
|
||||
test/upgrade/upgrade-verification.spec.ts
|
||||
env:
|
||||
HOMEBOX_URL: http://localhost:7745
|
||||
|
||||
# Step 10: Upload reports for review
|
||||
|
||||
- name: Upload Playwright report
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -157,7 +152,7 @@ jobs:
|
||||
name: playwright-report-upgrade-test
|
||||
path: frontend/playwright-report/
|
||||
retention-days: 30
|
||||
|
||||
|
||||
- name: Upload test traces
|
||||
uses: actions/upload-artifact@v4
|
||||
if: always()
|
||||
@@ -165,20 +160,18 @@ jobs:
|
||||
name: playwright-traces
|
||||
path: frontend/test-results/
|
||||
retention-days: 7
|
||||
|
||||
# Step 11: Collect logs for failed instances
|
||||
|
||||
- name: Collect logs on failure
|
||||
if: failure()
|
||||
run: |
|
||||
echo "=== Docker logs for new version ==="
|
||||
docker logs homebox-new || true
|
||||
echo "=== Database content ==="
|
||||
ls -la /tmp/homebox-data-new || true
|
||||
|
||||
# Step 12: Cleanup resources
|
||||
ls -la /tmp/homebox-data-new/ || true
|
||||
|
||||
- name: Cleanup
|
||||
if: always()
|
||||
run: |
|
||||
docker stop homebox-new || true
|
||||
docker rm homebox-new || true
|
||||
docker rmi homebox:test || true
|
||||
docker rmi homebox:test || true
|
||||
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -67,3 +67,5 @@ frontend/test-results/
|
||||
frontend/playwright-report/
|
||||
frontend/blob-report/
|
||||
frontend/playwright/.cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
@@ -8,7 +8,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251212183312-2d1d3d927bfd h1:QULUJSgHc4rSlTjb2qYT6FIgwDWFCqEpnYqc/ltsrkk=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251212183312-2d1d3d927bfd/go.mod h1:jB+tPmHtPDM1VnAjah0gvcRfP/s7c+rtQwpA8cvZD/U=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251228172914-2a6773d1d610 h1:kNLtnxaPaOryBUZ7RgUHPQVWxIExXYR/q9pYCbum5Vk=
|
||||
github.com/sysadminsmedia/homebox/backend v0.0.0-20251228172914-2a6773d1d610/go.mod h1:9zHHw5TNttw5Kn4Wks+SxwXmJPz6PgGNbnB4BtF1Z4c=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -17,8 +17,6 @@ builds:
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- "386"
|
||||
- arm
|
||||
- arm64
|
||||
- riscv64
|
||||
flags:
|
||||
@@ -28,20 +26,9 @@ builds:
|
||||
- -X main.version={{.Version}}
|
||||
- -X main.commit={{.Commit}}
|
||||
- -X main.date={{.Date}}
|
||||
ignore:
|
||||
- goos: windows
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: "386"
|
||||
tags:
|
||||
- >-
|
||||
{{- if eq .Arch "riscv64" }}nodynamic
|
||||
{{- else if eq .Arch "arm" }}nodynamic
|
||||
{{- else if eq .Arch "386" }}nodynamic
|
||||
{{- else if eq .Os "freebsd" }}nodynamic
|
||||
{{ end }}
|
||||
|
||||
@@ -62,7 +49,6 @@ archives:
|
||||
{{ .ProjectName }}_
|
||||
{{- title .Os }}_
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
# use zip for windows archives
|
||||
|
||||
@@ -2,6 +2,7 @@ package v1
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -9,6 +10,7 @@ import (
|
||||
"github.com/hay-kot/httpkit/server"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
|
||||
)
|
||||
|
||||
@@ -94,3 +96,64 @@ func (ctrl *V1Controller) HandleSetPrimaryPhotos() errchain.HandlerFunc {
|
||||
func (ctrl *V1Controller) HandleCreateMissingThumbnails() errchain.HandlerFunc {
|
||||
return actionHandlerFactory("create missing thumbnails", ctrl.repo.Attachments.CreateMissingThumbnails)
|
||||
}
|
||||
|
||||
// WipeInventoryOptions represents the options for wiping inventory
|
||||
type WipeInventoryOptions struct {
|
||||
WipeLabels bool `json:"wipeLabels"`
|
||||
WipeLocations bool `json:"wipeLocations"`
|
||||
WipeMaintenance bool `json:"wipeMaintenance"`
|
||||
}
|
||||
|
||||
// HandleWipeInventory godoc
|
||||
//
|
||||
// @Summary Wipe Inventory
|
||||
// @Description Deletes all items in the inventory
|
||||
// @Tags Actions
|
||||
// @Produce json
|
||||
// @Param options body WipeInventoryOptions false "Wipe options"
|
||||
// @Success 200 {object} ActionAmountResult
|
||||
// @Router /v1/actions/wipe-inventory [Post]
|
||||
// @Security Bearer
|
||||
func (ctrl *V1Controller) HandleWipeInventory() errchain.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) error {
|
||||
if ctrl.isDemo {
|
||||
return validate.NewRequestError(errors.New("wipe inventory is not allowed in demo mode"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
ctx := services.NewContext(r.Context())
|
||||
|
||||
// Check if user is owner
|
||||
if !ctx.User.IsOwner {
|
||||
return validate.NewRequestError(errors.New("only group owners can wipe inventory"), http.StatusForbidden)
|
||||
}
|
||||
|
||||
// Parse options from request body
|
||||
var options WipeInventoryOptions
|
||||
if err := server.Decode(r, &options); err != nil {
|
||||
// If no body provided, use default (false for all)
|
||||
options = WipeInventoryOptions{
|
||||
WipeLabels: false,
|
||||
WipeLocations: false,
|
||||
WipeMaintenance: false,
|
||||
}
|
||||
}
|
||||
|
||||
totalCompleted, err := ctrl.repo.Items.WipeInventory(ctx, ctx.GID, options.WipeLabels, options.WipeLocations, options.WipeMaintenance)
|
||||
if err != nil {
|
||||
log.Err(err).Str("action_ref", "wipe inventory").Msg("failed to run action")
|
||||
return validate.NewRequestError(err, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// Publish mutation events for wiped resources
|
||||
if ctrl.bus != nil {
|
||||
if options.WipeLabels {
|
||||
ctrl.bus.Publish(eventbus.EventLabelMutation, eventbus.GroupMutationEvent{GID: ctx.GID})
|
||||
}
|
||||
if options.WipeLocations {
|
||||
ctrl.bus.Publish(eventbus.EventLocationMutation, eventbus.GroupMutationEvent{GID: ctx.GID})
|
||||
}
|
||||
}
|
||||
|
||||
return server.JSON(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,7 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
|
||||
r.Post("/actions/ensure-import-refs", chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
|
||||
r.Post("/actions/set-primary-photos", chain.ToHandlerFunc(v1Ctrl.HandleSetPrimaryPhotos(), userMW...))
|
||||
r.Post("/actions/create-missing-thumbnails", chain.ToHandlerFunc(v1Ctrl.HandleCreateMissingThumbnails(), userMW...))
|
||||
r.Post("/actions/wipe-inventory", chain.ToHandlerFunc(v1Ctrl.HandleWipeInventory(), userMW...))
|
||||
|
||||
r.Get("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
|
||||
r.Post("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
|
||||
|
||||
@@ -118,6 +118,41 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Wipe options",
|
||||
"name": "options",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -5184,6 +5219,20 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -114,6 +114,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wipe options"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -5381,6 +5417,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -67,6 +67,27 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.ActionAmountResult"
|
||||
/v1/actions/wipe-inventory:
|
||||
post:
|
||||
security:
|
||||
- Bearer: []
|
||||
description: Deletes all items in the inventory
|
||||
tags:
|
||||
- Actions
|
||||
summary: Wipe Inventory
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.WipeInventoryOptions"
|
||||
description: Wipe options
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.ActionAmountResult"
|
||||
/v1/actions/zero-item-time-fields:
|
||||
post:
|
||||
security:
|
||||
@@ -3449,6 +3470,15 @@ components:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
v1.WipeInventoryOptions:
|
||||
type: object
|
||||
properties:
|
||||
wipeLabels:
|
||||
type: boolean
|
||||
wipeLocations:
|
||||
type: boolean
|
||||
wipeMaintenance:
|
||||
type: boolean
|
||||
v1.Wrapped:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -116,6 +116,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Wipe options",
|
||||
"name": "options",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -5182,6 +5217,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1867,6 +1867,15 @@ definitions:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
v1.WipeInventoryOptions:
|
||||
properties:
|
||||
wipeLabels:
|
||||
type: boolean
|
||||
wipeLocations:
|
||||
type: boolean
|
||||
wipeMaintenance:
|
||||
type: boolean
|
||||
type: object
|
||||
v1.Wrapped:
|
||||
properties:
|
||||
item: {}
|
||||
@@ -1947,6 +1956,27 @@ paths:
|
||||
summary: Set Primary Photos
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/wipe-inventory:
|
||||
post:
|
||||
description: Deletes all items in the inventory
|
||||
parameters:
|
||||
- description: Wipe options
|
||||
in: body
|
||||
name: options
|
||||
schema:
|
||||
$ref: '#/definitions/v1.WipeInventoryOptions'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/v1.ActionAmountResult'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Wipe Inventory
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/zero-item-time-fields:
|
||||
post:
|
||||
description: Resets all item date fields to the beginning of the day
|
||||
|
||||
@@ -809,6 +809,88 @@ func (e *ItemsRepository) DeleteByGroup(ctx context.Context, gid, id uuid.UUID)
|
||||
return err
|
||||
}
|
||||
|
||||
func (e *ItemsRepository) WipeInventory(ctx context.Context, gid uuid.UUID, wipeLabels bool, wipeLocations bool, wipeMaintenance bool) (int, error) {
|
||||
deleted := 0
|
||||
|
||||
// Wipe maintenance records if requested
|
||||
// IMPORTANT: Must delete maintenance records BEFORE items since they are linked to items
|
||||
if wipeMaintenance {
|
||||
maintenanceCount, err := e.db.MaintenanceEntry.Delete().
|
||||
Where(maintenanceentry.HasItemWith(item.HasGroupWith(group.ID(gid)))).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete maintenance entries during wipe inventory")
|
||||
} else {
|
||||
log.Info().Int("count", maintenanceCount).Msg("deleted maintenance entries during wipe inventory")
|
||||
deleted += maintenanceCount
|
||||
}
|
||||
}
|
||||
|
||||
// Get all items for the group
|
||||
items, err := e.db.Item.Query().
|
||||
Where(item.HasGroupWith(group.ID(gid))).
|
||||
WithAttachments().
|
||||
All(ctx)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
// Delete each item with its attachments
|
||||
// Note: We manually delete attachments and items instead of calling DeleteByGroup
|
||||
// to continue processing remaining items even if some deletions fail
|
||||
for _, itm := range items {
|
||||
// Delete all attachments first
|
||||
for _, att := range itm.Edges.Attachments {
|
||||
err := e.attachments.Delete(ctx, gid, itm.ID, att.ID)
|
||||
if err != nil {
|
||||
log.Err(err).Str("attachment_id", att.ID.String()).Msg("failed to delete attachment during wipe inventory")
|
||||
// Continue with other attachments even if one fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the item
|
||||
_, err = e.db.Item.
|
||||
Delete().
|
||||
Where(
|
||||
item.ID(itm.ID),
|
||||
item.HasGroupWith(group.ID(gid)),
|
||||
).Exec(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Str("item_id", itm.ID.String()).Msg("failed to delete item during wipe inventory")
|
||||
// Skip to next item without incrementing counter
|
||||
continue
|
||||
}
|
||||
|
||||
// Only increment counter if deletion succeeded
|
||||
deleted++
|
||||
}
|
||||
|
||||
// Wipe labels if requested
|
||||
if wipeLabels {
|
||||
labelCount, err := e.db.Label.Delete().Where(label.HasGroupWith(group.ID(gid))).Exec(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete labels during wipe inventory")
|
||||
} else {
|
||||
log.Info().Int("count", labelCount).Msg("deleted labels during wipe inventory")
|
||||
deleted += labelCount
|
||||
}
|
||||
}
|
||||
|
||||
// Wipe locations if requested
|
||||
if wipeLocations {
|
||||
locationCount, err := e.db.Location.Delete().Where(location.HasGroupWith(group.ID(gid))).Exec(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to delete locations during wipe inventory")
|
||||
} else {
|
||||
log.Info().Int("count", locationCount).Msg("deleted locations during wipe inventory")
|
||||
deleted += locationCount
|
||||
}
|
||||
}
|
||||
|
||||
e.publishMutationEvent(gid)
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
func (e *ItemsRepository) UpdateByGroup(ctx context.Context, gid uuid.UUID, data ItemUpdate) (ItemOut, error) {
|
||||
q := e.db.Item.Update().Where(item.ID(data.ID), item.HasGroupWith(group.ID(gid))).
|
||||
SetName(data.Name).
|
||||
|
||||
@@ -398,4 +398,161 @@ func TestItemsRepository_DeleteByGroupWithAttachments(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestItemsRepository_WipeInventory(t *testing.T) {
|
||||
// Create test data: items, labels, locations, and maintenance entries
|
||||
|
||||
// Create locations
|
||||
loc1, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Location 1",
|
||||
Description: "Test location for wipe test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loc2, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Location 2",
|
||||
Description: "Another test location",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create labels
|
||||
label1, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Label 1",
|
||||
Description: "Test label for wipe test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
label2, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Label 2",
|
||||
Description: "Another test label",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create items
|
||||
item1, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Item 1",
|
||||
Description: "Test item for wipe test",
|
||||
LocationID: loc1.ID,
|
||||
LabelIDs: []uuid.UUID{label1.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
item2, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Item 2",
|
||||
Description: "Another test item",
|
||||
LocationID: loc2.ID,
|
||||
LabelIDs: []uuid.UUID{label2.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create maintenance entries for items
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item1.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Test Maintenance 1",
|
||||
Description: "Test maintenance entry",
|
||||
Cost: 100.0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item2.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Test Maintenance 2",
|
||||
Description: "Another test maintenance entry",
|
||||
Cost: 200.0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test 1: Wipe inventory with all options enabled
|
||||
t.Run("wipe all including labels, locations, and maintenance", func(t *testing.T) {
|
||||
deleted, err := tRepos.Items.WipeInventory(context.Background(), tGroup.ID, true, true, true)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, deleted, 0, "Should have deleted at least some entities")
|
||||
|
||||
// Verify items are deleted
|
||||
_, err = tRepos.Items.GetOneByGroup(context.Background(), tGroup.ID, item1.ID)
|
||||
require.Error(t, err, "Item 1 should be deleted")
|
||||
|
||||
_, err = tRepos.Items.GetOneByGroup(context.Background(), tGroup.ID, item2.ID)
|
||||
require.Error(t, err, "Item 2 should be deleted")
|
||||
|
||||
// Verify maintenance entries are deleted (query by item ID, should return empty)
|
||||
maint1List, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item1.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, maint1List, "Maintenance entry 1 should be deleted")
|
||||
|
||||
maint2List, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item2.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, maint2List, "Maintenance entry 2 should be deleted")
|
||||
|
||||
// Verify labels are deleted
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label1.ID)
|
||||
require.Error(t, err, "Label 1 should be deleted")
|
||||
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label2.ID)
|
||||
require.Error(t, err, "Label 2 should be deleted")
|
||||
|
||||
// Verify locations are deleted
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc1.ID)
|
||||
require.Error(t, err, "Location 1 should be deleted")
|
||||
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc2.ID)
|
||||
require.Error(t, err, "Location 2 should be deleted")
|
||||
})
|
||||
}
|
||||
|
||||
func TestItemsRepository_WipeInventory_OnlyItems(t *testing.T) {
|
||||
// Create test data
|
||||
loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Location",
|
||||
Description: "Test location for wipe test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
label, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Label",
|
||||
Description: "Test label for wipe test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
item, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Item",
|
||||
Description: "Test item for wipe test",
|
||||
LocationID: loc.ID,
|
||||
LabelIDs: []uuid.UUID{label.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Test Maintenance",
|
||||
Description: "Test maintenance entry",
|
||||
Cost: 100.0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test: Wipe inventory with only items (no labels, locations, or maintenance)
|
||||
deleted, err := tRepos.Items.WipeInventory(context.Background(), tGroup.ID, false, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, deleted, 0, "Should have deleted at least the item")
|
||||
|
||||
// Verify item is deleted
|
||||
_, err = tRepos.Items.GetOneByGroup(context.Background(), tGroup.ID, item.ID)
|
||||
require.Error(t, err, "Item should be deleted")
|
||||
|
||||
// Verify maintenance entry is deleted due to cascade
|
||||
maintList, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, maintList, "Maintenance entry should be cascade deleted with item")
|
||||
|
||||
// Verify label still exists
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label.ID)
|
||||
require.NoError(t, err, "Label should still exist")
|
||||
|
||||
// Verify location still exists
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc.ID)
|
||||
require.NoError(t, err, "Location should still exist")
|
||||
|
||||
// Cleanup
|
||||
_ = tRepos.Labels.DeleteByGroup(context.Background(), tGroup.ID, label.ID)
|
||||
_ = tRepos.Locations.delete(context.Background(), loc.ID)
|
||||
}
|
||||
|
||||
|
||||
194
backend/internal/data/repo/repo_wipe_integration_test.go
Normal file
194
backend/internal/data/repo/repo_wipe_integration_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/types"
|
||||
)
|
||||
|
||||
// TestWipeInventory_Integration tests the complete wipe inventory flow
|
||||
func TestWipeInventory_Integration(t *testing.T) {
|
||||
// Create test data: locations, labels, items with maintenance
|
||||
|
||||
// 1. Create locations
|
||||
loc1, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Garage",
|
||||
Description: "Garage location",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
loc2, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Basement",
|
||||
Description: "Basement location",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 2. Create labels
|
||||
label1, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Electronics",
|
||||
Description: "Electronics label",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
label2, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Tools",
|
||||
Description: "Tools label",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 3. Create items
|
||||
item1, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Laptop",
|
||||
Description: "Work laptop",
|
||||
LocationID: loc1.ID,
|
||||
LabelIDs: []uuid.UUID{label1.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
item2, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Drill",
|
||||
Description: "Power drill",
|
||||
LocationID: loc2.ID,
|
||||
LabelIDs: []uuid.UUID{label2.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
item3, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Monitor",
|
||||
Description: "Computer monitor",
|
||||
LocationID: loc1.ID,
|
||||
LabelIDs: []uuid.UUID{label1.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 4. Create maintenance entries
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item1.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Laptop cleaning",
|
||||
Description: "Cleaned keyboard and screen",
|
||||
Cost: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item2.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Drill maintenance",
|
||||
Description: "Oiled motor",
|
||||
Cost: 5.00,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item3.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "Monitor calibration",
|
||||
Description: "Color calibration",
|
||||
Cost: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 5. Verify items exist
|
||||
allItems, err := tRepos.Items.GetAll(context.Background(), tGroup.ID)
|
||||
require.NoError(t, err)
|
||||
assert.GreaterOrEqual(t, len(allItems), 3, "Should have at least 3 items")
|
||||
|
||||
// 6. Verify maintenance entries exist
|
||||
maint1List, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item1.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, maint1List, "Item 1 should have maintenance records")
|
||||
|
||||
maint2List, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item2.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.NotEmpty(t, maint2List, "Item 2 should have maintenance records")
|
||||
|
||||
// 7. Test wipe inventory with all options enabled
|
||||
deleted, err := tRepos.Items.WipeInventory(context.Background(), tGroup.ID, true, true, true)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, deleted, 0, "Should have deleted entities")
|
||||
|
||||
// 8. Verify all items are deleted
|
||||
allItemsAfter, err := tRepos.Items.GetAll(context.Background(), tGroup.ID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 0, len(allItemsAfter), "All items should be deleted")
|
||||
|
||||
// 9. Verify maintenance entries are deleted
|
||||
maint1After, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item1.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, maint1After, "Item 1 maintenance records should be deleted")
|
||||
|
||||
// 10. Verify labels are deleted
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label1.ID)
|
||||
require.Error(t, err, "Label 1 should be deleted")
|
||||
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label2.ID)
|
||||
require.Error(t, err, "Label 2 should be deleted")
|
||||
|
||||
// 11. Verify locations are deleted
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc1.ID)
|
||||
require.Error(t, err, "Location 1 should be deleted")
|
||||
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc2.ID)
|
||||
require.Error(t, err, "Location 2 should be deleted")
|
||||
}
|
||||
|
||||
// TestWipeInventory_SelectiveWipe tests wiping only certain entity types
|
||||
func TestWipeInventory_SelectiveWipe(t *testing.T) {
|
||||
// Create test data
|
||||
loc, err := tRepos.Locations.Create(context.Background(), tGroup.ID, LocationCreate{
|
||||
Name: "Test Office",
|
||||
Description: "Office location",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
label, err := tRepos.Labels.Create(context.Background(), tGroup.ID, LabelCreate{
|
||||
Name: "Test Important",
|
||||
Description: "Important label",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
item, err := tRepos.Items.Create(context.Background(), tGroup.ID, ItemCreate{
|
||||
Name: "Test Computer",
|
||||
Description: "Desktop computer",
|
||||
LocationID: loc.ID,
|
||||
LabelIDs: []uuid.UUID{label.ID},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = tRepos.MaintEntry.Create(context.Background(), item.ID, MaintenanceEntryCreate{
|
||||
CompletedDate: types.DateFromTime(time.Now()),
|
||||
Name: "System update",
|
||||
Description: "OS update",
|
||||
Cost: 0,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Test: Wipe only items (keep labels and locations)
|
||||
deleted, err := tRepos.Items.WipeInventory(context.Background(), tGroup.ID, false, false, false)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, deleted, 0, "Should have deleted at least items")
|
||||
|
||||
// Verify item is deleted
|
||||
_, err = tRepos.Items.GetOneByGroup(context.Background(), tGroup.ID, item.ID)
|
||||
require.Error(t, err, "Item should be deleted")
|
||||
|
||||
// Verify maintenance is cascade deleted
|
||||
maintList, err := tRepos.MaintEntry.GetMaintenanceByItemID(context.Background(), tGroup.ID, item.ID, MaintenanceFilters{})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, maintList, "Maintenance should be cascade deleted")
|
||||
|
||||
// Verify label still exists
|
||||
_, err = tRepos.Labels.GetOneByGroup(context.Background(), tGroup.ID, label.ID)
|
||||
require.NoError(t, err, "Label should still exist")
|
||||
|
||||
// Verify location still exists
|
||||
_, err = tRepos.Locations.Get(context.Background(), loc.ID)
|
||||
require.NoError(t, err, "Location should still exist")
|
||||
|
||||
// Cleanup
|
||||
_ = tRepos.Labels.DeleteByGroup(context.Background(), tGroup.ID, label.ID)
|
||||
_ = tRepos.Locations.delete(context.Background(), loc.ID)
|
||||
}
|
||||
@@ -116,6 +116,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Wipe options",
|
||||
"name": "options",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -4032,7 +4067,8 @@
|
||||
"properties": {
|
||||
"defaultDescription": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
"maxLength": 1000,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultInsured": {
|
||||
"type": "boolean"
|
||||
@@ -4041,34 +4077,41 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultLifetimeWarranty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLocationId": {
|
||||
"description": "Default location and labels",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultManufacturer": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultModelNumber": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultName": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultQuantity": {
|
||||
"description": "Default values for items",
|
||||
"type": "integer"
|
||||
"type": "integer",
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultWarrantyDetails": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
"maxLength": 1000,
|
||||
"x-nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
@@ -4209,7 +4252,8 @@
|
||||
"properties": {
|
||||
"defaultDescription": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
"maxLength": 1000,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultInsured": {
|
||||
"type": "boolean"
|
||||
@@ -4218,34 +4262,41 @@
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultLifetimeWarranty": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"defaultLocationId": {
|
||||
"description": "Default location and labels",
|
||||
"type": "string"
|
||||
"type": "string",
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultManufacturer": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultModelNumber": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultName": {
|
||||
"type": "string",
|
||||
"maxLength": 255
|
||||
"maxLength": 255,
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultQuantity": {
|
||||
"description": "Default values for items",
|
||||
"type": "integer"
|
||||
"type": "integer",
|
||||
"x-nullable": true
|
||||
},
|
||||
"defaultWarrantyDetails": {
|
||||
"type": "string",
|
||||
"maxLength": 1000
|
||||
"maxLength": 1000,
|
||||
"x-nullable": true
|
||||
},
|
||||
"description": {
|
||||
"type": "string",
|
||||
@@ -5166,6 +5217,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1084,32 +1084,40 @@ definitions:
|
||||
defaultDescription:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultInsured:
|
||||
type: boolean
|
||||
defaultLabelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
defaultLifetimeWarranty:
|
||||
type: boolean
|
||||
defaultLocationId:
|
||||
description: Default location and labels
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultManufacturer:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultModelNumber:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultName:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultQuantity:
|
||||
description: Default values for items
|
||||
type: integer
|
||||
x-nullable: true
|
||||
defaultWarrantyDetails:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
x-nullable: true
|
||||
description:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
@@ -1205,32 +1213,40 @@ definitions:
|
||||
defaultDescription:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultInsured:
|
||||
type: boolean
|
||||
defaultLabelIds:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
x-nullable: true
|
||||
defaultLifetimeWarranty:
|
||||
type: boolean
|
||||
defaultLocationId:
|
||||
description: Default location and labels
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultManufacturer:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultModelNumber:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultName:
|
||||
maxLength: 255
|
||||
type: string
|
||||
x-nullable: true
|
||||
defaultQuantity:
|
||||
description: Default values for items
|
||||
type: integer
|
||||
x-nullable: true
|
||||
defaultWarrantyDetails:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
x-nullable: true
|
||||
description:
|
||||
maxLength: 1000
|
||||
type: string
|
||||
@@ -1851,6 +1867,15 @@ definitions:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
v1.WipeInventoryOptions:
|
||||
properties:
|
||||
wipeLabels:
|
||||
type: boolean
|
||||
wipeLocations:
|
||||
type: boolean
|
||||
wipeMaintenance:
|
||||
type: boolean
|
||||
type: object
|
||||
v1.Wrapped:
|
||||
properties:
|
||||
item: {}
|
||||
@@ -1931,6 +1956,27 @@ paths:
|
||||
summary: Set Primary Photos
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/wipe-inventory:
|
||||
post:
|
||||
description: Deletes all items in the inventory
|
||||
parameters:
|
||||
- description: Wipe options
|
||||
in: body
|
||||
name: options
|
||||
schema:
|
||||
$ref: '#/definitions/v1.WipeInventoryOptions'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/v1.ActionAmountResult'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Wipe Inventory
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/zero-item-time-fields:
|
||||
post:
|
||||
description: Resets all item date fields to the beginning of the day
|
||||
|
||||
@@ -114,6 +114,42 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Wipe options"
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -5381,6 +5417,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -67,6 +67,27 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.ActionAmountResult"
|
||||
/v1/actions/wipe-inventory:
|
||||
post:
|
||||
security:
|
||||
- Bearer: []
|
||||
description: Deletes all items in the inventory
|
||||
tags:
|
||||
- Actions
|
||||
summary: Wipe Inventory
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.WipeInventoryOptions"
|
||||
description: Wipe options
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/v1.ActionAmountResult"
|
||||
/v1/actions/zero-item-time-fields:
|
||||
post:
|
||||
security:
|
||||
@@ -3449,6 +3470,15 @@ components:
|
||||
type: string
|
||||
token:
|
||||
type: string
|
||||
v1.WipeInventoryOptions:
|
||||
type: object
|
||||
properties:
|
||||
wipeLabels:
|
||||
type: boolean
|
||||
wipeLocations:
|
||||
type: boolean
|
||||
wipeMaintenance:
|
||||
type: boolean
|
||||
v1.Wrapped:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -116,6 +116,41 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/wipe-inventory": {
|
||||
"post": {
|
||||
"security": [
|
||||
{
|
||||
"Bearer": []
|
||||
}
|
||||
],
|
||||
"description": "Deletes all items in the inventory",
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Actions"
|
||||
],
|
||||
"summary": "Wipe Inventory",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "Wipe options",
|
||||
"name": "options",
|
||||
"in": "body",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.WipeInventoryOptions"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "OK",
|
||||
"schema": {
|
||||
"$ref": "#/definitions/v1.ActionAmountResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/v1/actions/zero-item-time-fields": {
|
||||
"post": {
|
||||
"security": [
|
||||
@@ -5182,6 +5217,20 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.WipeInventoryOptions": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"wipeLabels": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeLocations": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"wipeMaintenance": {
|
||||
"type": "boolean"
|
||||
}
|
||||
}
|
||||
},
|
||||
"v1.Wrapped": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -1867,6 +1867,15 @@ definitions:
|
||||
token:
|
||||
type: string
|
||||
type: object
|
||||
v1.WipeInventoryOptions:
|
||||
properties:
|
||||
wipeLabels:
|
||||
type: boolean
|
||||
wipeLocations:
|
||||
type: boolean
|
||||
wipeMaintenance:
|
||||
type: boolean
|
||||
type: object
|
||||
v1.Wrapped:
|
||||
properties:
|
||||
item: {}
|
||||
@@ -1947,6 +1956,27 @@ paths:
|
||||
summary: Set Primary Photos
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/wipe-inventory:
|
||||
post:
|
||||
description: Deletes all items in the inventory
|
||||
parameters:
|
||||
- description: Wipe options
|
||||
in: body
|
||||
name: options
|
||||
schema:
|
||||
$ref: '#/definitions/v1.WipeInventoryOptions'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"200":
|
||||
description: OK
|
||||
schema:
|
||||
$ref: '#/definitions/v1.ActionAmountResult'
|
||||
security:
|
||||
- Bearer: []
|
||||
summary: Wipe Inventory
|
||||
tags:
|
||||
- Actions
|
||||
/v1/actions/zero-item-time-fields:
|
||||
post:
|
||||
description: Resets all item date fields to the beginning of the day
|
||||
|
||||
129
frontend/components/WipeInventoryDialog.vue
Normal file
129
frontend/components/WipeInventoryDialog.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<AlertDialog :open="dialog" @update:open="handleOpenChange">
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{{ $t("tools.actions_set.wipe_inventory") }}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{{ $t("tools.actions_set.wipe_inventory_confirm") }}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
id="wipe-labels-checkbox"
|
||||
v-model="wipeLabels"
|
||||
type="checkbox"
|
||||
class="size-4 rounded border-gray-300"
|
||||
/>
|
||||
<label for="wipe-labels-checkbox" class="cursor-pointer text-sm font-medium">
|
||||
{{ $t("tools.actions_set.wipe_inventory_labels") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
id="wipe-locations-checkbox"
|
||||
v-model="wipeLocations"
|
||||
type="checkbox"
|
||||
class="size-4 rounded border-gray-300"
|
||||
/>
|
||||
<label for="wipe-locations-checkbox" class="cursor-pointer text-sm font-medium">
|
||||
{{ $t("tools.actions_set.wipe_inventory_locations") }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-2">
|
||||
<input
|
||||
id="wipe-maintenance-checkbox"
|
||||
v-model="wipeMaintenance"
|
||||
type="checkbox"
|
||||
class="size-4 rounded border-gray-300"
|
||||
/>
|
||||
<label for="wipe-maintenance-checkbox" class="cursor-pointer text-sm font-medium">
|
||||
{{ $t("tools.actions_set.wipe_inventory_maintenance") }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-gray-600">
|
||||
{{ $t("tools.actions_set.wipe_inventory_note") }}
|
||||
</p>
|
||||
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel @click="close">
|
||||
{{ $t("global.cancel") }}
|
||||
</AlertDialogCancel>
|
||||
<Button @click="confirm">
|
||||
{{ $t("global.confirm") }}
|
||||
</Button>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DialogID } from "~/components/ui/dialog-provider/utils";
|
||||
import { useDialog } from "~/components/ui/dialog-provider";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
const { registerOpenDialogCallback, closeDialog, addAlert, removeAlert } = useDialog();
|
||||
|
||||
const dialog = ref(false);
|
||||
const wipeLabels = ref(false);
|
||||
const wipeLocations = ref(false);
|
||||
const wipeMaintenance = ref(false);
|
||||
const isConfirming = ref(false);
|
||||
|
||||
registerOpenDialogCallback(DialogID.WipeInventory, () => {
|
||||
dialog.value = true;
|
||||
wipeLabels.value = false;
|
||||
wipeLocations.value = false;
|
||||
wipeMaintenance.value = false;
|
||||
isConfirming.value = false;
|
||||
});
|
||||
|
||||
watch(
|
||||
dialog,
|
||||
val => {
|
||||
if (val) {
|
||||
addAlert("wipe-inventory-dialog");
|
||||
} else {
|
||||
removeAlert("wipe-inventory-dialog");
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
function handleOpenChange(open: boolean) {
|
||||
if (!open && !isConfirming.value) {
|
||||
close();
|
||||
}
|
||||
}
|
||||
|
||||
function close() {
|
||||
dialog.value = false;
|
||||
closeDialog(DialogID.WipeInventory, undefined);
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
isConfirming.value = true;
|
||||
const result = {
|
||||
wipeLabels: wipeLabels.value,
|
||||
wipeLocations: wipeLocations.value,
|
||||
wipeMaintenance: wipeMaintenance.value,
|
||||
};
|
||||
closeDialog(DialogID.WipeInventory, result);
|
||||
dialog.value = false;
|
||||
isConfirming.value = false;
|
||||
}
|
||||
</script>
|
||||
@@ -26,6 +26,7 @@ export enum DialogID {
|
||||
UpdateLocation = "update-location",
|
||||
UpdateTemplate = "update-template",
|
||||
ItemChangeDetails = "item-table-updater",
|
||||
WipeInventory = "wipe-inventory",
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -71,6 +72,7 @@ export type DialogResultMap = {
|
||||
[DialogID.ItemImage]?: { action: "delete"; id: string };
|
||||
[DialogID.EditMaintenance]?: boolean;
|
||||
[DialogID.ItemChangeDetails]?: boolean;
|
||||
[DialogID.WipeInventory]?: { wipeLabels: boolean; wipeLocations: boolean; wipeMaintenance: boolean };
|
||||
};
|
||||
|
||||
/** Helpers to split IDs by requirement */
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
<ModalConfirm />
|
||||
<OutdatedModal v-if="status" :status="status" />
|
||||
<ItemCreateModal />
|
||||
<WipeInventoryDialog />
|
||||
<LabelCreateModal />
|
||||
<LocationCreateModal />
|
||||
<ItemBarcodeModal />
|
||||
@@ -216,6 +217,7 @@
|
||||
import ModalConfirm from "~/components/ModalConfirm.vue";
|
||||
import OutdatedModal from "~/components/App/OutdatedModal.vue";
|
||||
import ItemCreateModal from "~/components/Item/CreateModal.vue";
|
||||
import WipeInventoryDialog from "~/components/WipeInventoryDialog.vue";
|
||||
|
||||
import LabelCreateModal from "~/components/Label/CreateModal.vue";
|
||||
import LocationCreateModal from "~/components/Location/CreateModal.vue";
|
||||
|
||||
@@ -31,4 +31,14 @@ export class ActionsAPI extends BaseAPI {
|
||||
url: route("/actions/create-missing-thumbnails"),
|
||||
});
|
||||
}
|
||||
|
||||
wipeInventory(options?: { wipeLabels?: boolean; wipeLocations?: boolean; wipeMaintenance?: boolean }) {
|
||||
return this.http.post<
|
||||
{ wipeLabels?: boolean; wipeLocations?: boolean; wipeMaintenance?: boolean },
|
||||
ActionAmountResult
|
||||
>({
|
||||
url: route("/actions/wipe-inventory"),
|
||||
body: options || {},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
6
frontend/lib/api/types/data-contracts.ts
generated
6
frontend/lib/api/types/data-contracts.ts
generated
@@ -1150,6 +1150,12 @@ export interface TokenResponse {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface WipeInventoryOptions {
|
||||
wipeLabels: boolean;
|
||||
wipeLocations: boolean;
|
||||
wipeMaintenance: boolean;
|
||||
}
|
||||
|
||||
export interface Wrapped {
|
||||
item: any;
|
||||
}
|
||||
|
||||
@@ -521,42 +521,45 @@
|
||||
"update_label": "Update Label"
|
||||
},
|
||||
"languages": {
|
||||
"bs-BA": "Bosnian (Bosnia and Herzegovina)",
|
||||
"ca": "Catalan",
|
||||
"cs-CZ": "Czech",
|
||||
"da-DK": "Danish",
|
||||
"de": "German",
|
||||
"ar-AA": "Arabic (العربية)",
|
||||
"bs-BA": "Bosnian (bosanski)",
|
||||
"ca": "Catalan (català)",
|
||||
"cs-CZ": "Czech (čeština)",
|
||||
"da-DK": "Danish (dansk)",
|
||||
"de": "German (Deutsch)",
|
||||
"el-GR": "Greek (Ελληνικά)",
|
||||
"en": "English",
|
||||
"es": "Spanish",
|
||||
"fi-FI": "Finnish",
|
||||
"fr": "French",
|
||||
"hu": "Hungarian",
|
||||
"id-ID": "Indonesian",
|
||||
"it": "Italian",
|
||||
"ja-JP": "Japanese",
|
||||
"ko-KR": "Korean",
|
||||
"lb-LU": "Luxembourgish (Luxembourg)",
|
||||
"lt-LT": "Lithuanian (Lithuania)",
|
||||
"nb-NO": "Norwegian Bokmål",
|
||||
"nl": "Dutch",
|
||||
"pl": "Polish",
|
||||
"pt-BR": "Portuguese (Brazil)",
|
||||
"pt-PT": "Portuguese (Portugal)",
|
||||
"ro-RO": "Romanian",
|
||||
"ru": "Russian",
|
||||
"sk-SK": "Slovak",
|
||||
"sl": "Slovenian",
|
||||
"sq-AL": "Albanian",
|
||||
"sv": "Swedish",
|
||||
"ta-IN": "Tamil",
|
||||
"th-TH": "Thai",
|
||||
"tr": "Turkish",
|
||||
"uk-UA": "Ukrainian",
|
||||
"vi-VN": "Vietnamese",
|
||||
"zh-CN": "Chinese (Simplified)",
|
||||
"zh-HK": "Chinese (Hong Kong)",
|
||||
"zh-MO": "Chinese (Macau)",
|
||||
"zh-TW": "Chinese (Traditional)"
|
||||
"es": "Spanish (español)",
|
||||
"fi-FI": "Finnish (suomi)",
|
||||
"fr": "French (français)",
|
||||
"hu": "Hungarian (magyar)",
|
||||
"id-ID": "Indonesian (Indonesia)",
|
||||
"it": "Italian (italiano)",
|
||||
"ja-JP": "Japanese (日本語)",
|
||||
"ko-KR": "Korean (한국어)",
|
||||
"lb-LU": "Luxembourgish (Lëtzebuergesch)",
|
||||
"lt-LT": "Lithuanian (lietuvių)",
|
||||
"nb-NO": "Norwegian Bokmål (norsk bokmål)",
|
||||
"nl": "Dutch (Nederlands)",
|
||||
"pl": "Polish (polski)",
|
||||
"pt-BR": "Portuguese - Brazil (português)",
|
||||
"pt-PT": "Portuguese - Portugal (português)",
|
||||
"ro-RO": "Romanian (română)",
|
||||
"ru": "Russian (русский)",
|
||||
"sk-SK": "Slovak (slovenčina)",
|
||||
"sl": "Slovenian (slovenščina)",
|
||||
"sq-AL": "Albanian (shqip)",
|
||||
"sv": "Swedish (svenska)",
|
||||
"ta-IN": "Tamil (தமிழ்)",
|
||||
"te-IN": "Telugu (తెలుగు)",
|
||||
"th-TH": "Thai (ไทย)",
|
||||
"tr": "Turkish (Türkçe)",
|
||||
"uk-UA": "Ukrainian (українська)",
|
||||
"vi-VN": "Vietnamese (Tiếng Việt)",
|
||||
"zh-CN": "Chinese - Simplified (中文)",
|
||||
"zh-HK": "Chinese - Hong Kong (中文)",
|
||||
"zh-MO": "Chinese - Macau (中文)",
|
||||
"zh-TW": "Chinese - Traditional (中文)"
|
||||
},
|
||||
"locations": {
|
||||
"child_locations": "Child Locations",
|
||||
@@ -735,12 +738,23 @@
|
||||
"set_primary_photo_button": "Set Primary Photo",
|
||||
"set_primary_photo_confirm": "Are you sure you want to set primary photos? This can take a while and cannot be undone.",
|
||||
"set_primary_photo_sub": "In version v0.10.0 of Homebox, the primary image field was added to attachments of type photo. This action will set the primary image field to the first image in the attachments array in the database, if it is not already set. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/pull/576\">'See GitHub PR #576'</a>'",
|
||||
"wipe_inventory": "Wipe Inventory",
|
||||
"wipe_inventory_button": "Wipe Inventory",
|
||||
"wipe_inventory_confirm": "Are you sure you want to wipe your entire inventory? This will delete all items and cannot be undone.",
|
||||
"wipe_inventory_labels": "Also wipe all labels (tags)",
|
||||
"wipe_inventory_locations": "Also wipe all locations",
|
||||
"wipe_inventory_maintenance": "Also wipe all maintenance records",
|
||||
"wipe_inventory_note": "Note: Only group owners can perform this action.",
|
||||
"wipe_inventory_sub": "Permanently deletes all items in your inventory. This action is irreversible and will remove all item data including attachments and photos.",
|
||||
"zero_datetimes": "Zero Item Date Times",
|
||||
"zero_datetimes_button": "Zero Item Date Times",
|
||||
"zero_datetimes_confirm": "Are you sure you want to reset all date and time values? This can take a while and cannot be undone.",
|
||||
"zero_datetimes_sub": "Resets the time value for all date time fields in your inventory to the beginning of the date. This is to fix a bug that was introduced early on in the development of the site that caused the time value to be stored with the time which caused issues with date fields displaying accurate values. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/issues/236\" target=\"_blank\">'See Github Issue #236 for more details.'</a>'"
|
||||
},
|
||||
"actions_sub": "Apply Actions to your inventory in bulk. These are irreversible actions. '<b>'Be careful.'</b>'",
|
||||
"demo_mode_error": {
|
||||
"wipe_inventory": "Inventory, labels, locations and maintenance records cannot be wiped whilst Homebox is in demo mode. Please ensure that you are not in demo mode and try again."
|
||||
},
|
||||
"import_export": "Import/Export",
|
||||
"import_export_set": {
|
||||
"export": "Export Inventory",
|
||||
@@ -768,7 +782,9 @@
|
||||
"failed_ensure_ids": "Failed to ensure asset IDs.",
|
||||
"failed_ensure_import_refs": "Failed to ensure import refs.",
|
||||
"failed_set_primary_photos": "Failed to set primary photos.",
|
||||
"failed_zero_datetimes": "Failed to reset date and time values."
|
||||
"failed_wipe_inventory": "Failed to wipe inventory.",
|
||||
"failed_zero_datetimes": "Failed to reset date and time values.",
|
||||
"wipe_inventory_success": "Successfully wiped inventory. { results } items deleted."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -115,7 +115,9 @@
|
||||
return;
|
||||
}
|
||||
|
||||
item.value.quantity = newQuantity;
|
||||
if (resp.data) {
|
||||
item.value = resp.data;
|
||||
}
|
||||
}
|
||||
|
||||
type FilteredAttachments = {
|
||||
|
||||
@@ -90,6 +90,12 @@
|
||||
<div v-html="DOMPurify.sanitize($t('tools.actions_set.create_missing_thumbnails_sub'))" />
|
||||
<template #button> {{ $t("tools.actions_set.create_missing_thumbnails_button") }} </template>
|
||||
</DetailAction>
|
||||
<DetailAction @action="wipeInventory">
|
||||
<template #title> {{ $t("tools.actions_set.wipe_inventory") }} </template>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-html="DOMPurify.sanitize($t('tools.actions_set.wipe_inventory_sub'))" />
|
||||
<template #button> {{ $t("tools.actions_set.wipe_inventory_button") }} </template>
|
||||
</DetailAction>
|
||||
</div>
|
||||
</BaseCard>
|
||||
</BaseContainer>
|
||||
@@ -126,6 +132,13 @@
|
||||
const api = useUserApi();
|
||||
const confirm = useConfirm();
|
||||
|
||||
// Fetch status to check for demo mode
|
||||
const pubApi = usePublicApi();
|
||||
const { data: status } = useAsyncData(async () => {
|
||||
const { data } = await pubApi.status();
|
||||
return data;
|
||||
});
|
||||
|
||||
function getBillOfMaterials() {
|
||||
const url = api.reports.billOfMaterialsURL();
|
||||
window.open(url, "_blank");
|
||||
@@ -220,6 +233,35 @@
|
||||
|
||||
toast.success(t("tools.toast.asset_success", { results: result.data.completed }));
|
||||
}
|
||||
|
||||
async function wipeInventory() {
|
||||
// Check if in demo mode
|
||||
if (status.value?.demo) {
|
||||
await confirm.open(t("tools.demo_mode_error.wipe_inventory"));
|
||||
return;
|
||||
}
|
||||
|
||||
openDialog(DialogID.WipeInventory, {
|
||||
onClose: async result => {
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
|
||||
const apiResult = await api.actions.wipeInventory({
|
||||
wipeLabels: result.wipeLabels,
|
||||
wipeLocations: result.wipeLocations,
|
||||
wipeMaintenance: result.wipeMaintenance,
|
||||
});
|
||||
|
||||
if (apiResult.error) {
|
||||
toast.error(t("tools.toast.failed_wipe_inventory"));
|
||||
return;
|
||||
}
|
||||
|
||||
toast.success(t("tools.toast.wipe_inventory_success", { results: apiResult.data.completed }));
|
||||
},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
129
frontend/test/e2e/wipe-inventory.browser.spec.ts
Normal file
129
frontend/test/e2e/wipe-inventory.browser.spec.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Wipe Inventory E2E Test", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Login as demo user (owner with permissions)
|
||||
await page.goto("/");
|
||||
await page.fill("input[type='text']", "demo@example.com");
|
||||
await page.fill("input[type='password']", "demo");
|
||||
await page.click("button[type='submit']");
|
||||
await expect(page).toHaveURL("/home");
|
||||
});
|
||||
|
||||
test("should open wipe inventory dialog with all options", async ({ page }) => {
|
||||
// Navigate to Tools page
|
||||
await page.goto("/tools");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Scroll to the bottom where wipe inventory is located
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Find and click the Wipe Inventory button
|
||||
const wipeButton = page.locator("button", { hasText: "Wipe Inventory" }).last();
|
||||
await expect(wipeButton).toBeVisible();
|
||||
await wipeButton.click();
|
||||
|
||||
// Wait for dialog to appear
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify dialog title is visible
|
||||
await expect(page.locator("text=Wipe Inventory").first()).toBeVisible();
|
||||
|
||||
// Verify all checkboxes are present
|
||||
await expect(page.locator("input#wipe-labels-checkbox")).toBeVisible();
|
||||
await expect(page.locator("input#wipe-locations-checkbox")).toBeVisible();
|
||||
await expect(page.locator("input#wipe-maintenance-checkbox")).toBeVisible();
|
||||
|
||||
// Verify labels for checkboxes
|
||||
await expect(page.locator("label[for='wipe-labels-checkbox']")).toBeVisible();
|
||||
await expect(page.locator("label[for='wipe-locations-checkbox']")).toBeVisible();
|
||||
await expect(page.locator("label[for='wipe-maintenance-checkbox']")).toBeVisible();
|
||||
|
||||
// Verify both Cancel and Confirm buttons are present
|
||||
await expect(page.locator("button", { hasText: "Cancel" })).toBeVisible();
|
||||
const confirmButton = page.locator("button", { hasText: "Confirm" });
|
||||
await expect(confirmButton).toBeVisible();
|
||||
|
||||
// Take screenshot of the modal
|
||||
await page.screenshot({
|
||||
path: "/tmp/playwright-logs/wipe-inventory-modal-initial.png",
|
||||
});
|
||||
console.log("✅ Screenshot saved: wipe-inventory-modal-initial.png");
|
||||
|
||||
// Check all three options
|
||||
await page.check("input#wipe-labels-checkbox");
|
||||
await page.check("input#wipe-locations-checkbox");
|
||||
await page.check("input#wipe-maintenance-checkbox");
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Verify checkboxes are checked
|
||||
await expect(page.locator("input#wipe-labels-checkbox")).toBeChecked();
|
||||
await expect(page.locator("input#wipe-locations-checkbox")).toBeChecked();
|
||||
await expect(page.locator("input#wipe-maintenance-checkbox")).toBeChecked();
|
||||
|
||||
// Take screenshot with all options checked
|
||||
await page.screenshot({
|
||||
path: "/tmp/playwright-logs/wipe-inventory-modal-options-checked.png",
|
||||
});
|
||||
console.log("✅ Screenshot saved: wipe-inventory-modal-options-checked.png");
|
||||
|
||||
// Click Confirm button
|
||||
await confirmButton.click();
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Wait for the dialog to close (verify button is no longer visible)
|
||||
await expect(confirmButton).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Check for success toast notification
|
||||
// The toast should contain text about items being deleted
|
||||
const toastLocator = page.locator("[role='status'], [class*='toast'], [class*='sonner']");
|
||||
await expect(toastLocator.first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Take screenshot of the page after confirmation
|
||||
await page.screenshot({
|
||||
path: "/tmp/playwright-logs/after-wipe-confirmation.png",
|
||||
fullPage: true,
|
||||
});
|
||||
console.log("✅ Screenshot saved: after-wipe-confirmation.png");
|
||||
|
||||
console.log("✅ Test completed successfully!");
|
||||
console.log("✅ Wipe Inventory dialog opened correctly");
|
||||
console.log("✅ All three options (labels, locations, maintenance) are available");
|
||||
console.log("✅ Confirm button triggers the action");
|
||||
console.log("✅ Dialog closes after confirmation");
|
||||
});
|
||||
|
||||
test("should cancel wipe inventory operation", async ({ page }) => {
|
||||
// Navigate to Tools page
|
||||
await page.goto("/tools");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Scroll to wipe inventory section
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
||||
await page.waitForTimeout(500);
|
||||
|
||||
// Click Wipe Inventory button
|
||||
const wipeButton = page.locator("button", { hasText: "Wipe Inventory" }).last();
|
||||
await wipeButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify dialog is open
|
||||
await expect(page.locator("text=Wipe Inventory").first()).toBeVisible();
|
||||
|
||||
// Click Cancel button
|
||||
const cancelButton = page.locator("button", { hasText: "Cancel" });
|
||||
await cancelButton.click();
|
||||
await page.waitForTimeout(1000);
|
||||
|
||||
// Verify dialog is closed
|
||||
await expect(page.locator("text=Wipe Inventory").first()).not.toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Take screenshot after cancel
|
||||
await page.screenshot({
|
||||
path: "/tmp/playwright-logs/after-cancel.png",
|
||||
});
|
||||
console.log("✅ Screenshot saved: after-cancel.png");
|
||||
console.log("✅ Cancel button works correctly");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user