Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
dd873a9
Add default group handling and user-groups relationship
tankerkiller125 Dec 27, 2025
70adb00
Merge branch 'main' into mk/multi-groups
tankerkiller125 Dec 28, 2025
3fcb1cc
Fix Sqlite migration (or at least make sure it doesn't wipe things
tankerkiller125 Dec 28, 2025
9f5095a
The basics of the app are working again now
tankerkiller125 Dec 28, 2025
f3b4c0c
Forgot to update the API stuff and data-contracts.ts
tankerkiller125 Dec 28, 2025
74bdaa4
Fix golang test
tankerkiller125 Dec 28, 2025
ab9b821
New API endpoints for basic group management
tankerkiller125 Dec 28, 2025
825eda0
Merge remote-tracking branch 'origin/main' into mk/multi-groups
tankerkiller125 Dec 28, 2025
6c4ce49
Add member management routes
tankerkiller125 Dec 28, 2025
e6c1a5a
Fix some tests
tankerkiller125 Dec 28, 2025
301098b
Go lint things
tankerkiller125 Dec 28, 2025
2e5a2c9
Fix front-end tests for groups
tankerkiller125 Dec 28, 2025
6dc801d
Merge branch 'main' into mk/multi-groups
tankerkiller125 Dec 29, 2025
6a1539a
fix: ensure all groups only returns groups user is part of
tankerkiller125 Jan 3, 2026
22a9451
Add the swagger/openapi stuff
tankerkiller125 Jan 3, 2026
091d8ad
feat: begin adding frontend for collection
tonyaellie Jan 3, 2026
3077d43
fix: collection id being wrong causes big problems
tonyaellie Jan 3, 2026
2bbd93e
Lets try this for the new tenant switching and events.
tankerkiller125 Jan 3, 2026
e8dd14a
Merge remote-tracking branch 'origin/mk/multi-groups' into mk/multi-g…
tankerkiller125 Jan 3, 2026
a75b498
Adds invitation acceptance and deletion endpoints
tankerkiller125 Jan 3, 2026
557383a
Return information about group after accepting invitation
tankerkiller125 Jan 3, 2026
24a49dd
Forgot the swag stuff
tankerkiller125 Jan 3, 2026
4fdb060
feat: join collection
tonyaellie Jan 3, 2026
adaf719
feat: customize group name during creation
tankerkiller125 Jan 3, 2026
7c1ac46
feat: refactor collection management to use new composable and remove…
tonyaellie Jan 4, 2026
a8ca8e8
chore: make sure users can't join group they already exist in
tankerkiller125 Jan 4, 2026
b006e9d
Merge remote-tracking branch 'origin/mk/multi-groups' into mk/multi-g…
tankerkiller125 Jan 4, 2026
443a442
weird merge issue or something?
tankerkiller125 Jan 4, 2026
4d3f3f7
feat: begin adding collection pages
tonyaellie Jan 4, 2026
a1dfdbf
feat: implement collection options pages
tonyaellie Jan 5, 2026
f40d3d5
Limit user information returned on all user endpoint
tankerkiller125 Jan 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions backend/app/api/demo.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ import (

"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
)

func (a *app) SetupDemo() error {
csvText := `HB.import_ref,HB.location,HB.labels,HB.quantity,HB.name,HB.description,HB.insured,HB.serial_number,HB.model_number,HB.manufacturer,HB.notes,HB.purchase_from,HB.purchase_price,HB.purchase_time,HB.lifetime_warranty,HB.warranty_expires,HB.warranty_details,HB.sold_to,HB.sold_price,HB.sold_time,HB.sold_notes
,Garage,IOT;Home Assistant; Z-Wave,1,Zooz Universal Relay ZEN17,"Zooz 700 Series Z-Wave Universal Relay ZEN17 for Awnings, Garage Doors, Sprinklers, and More | 2 NO-C-NC Relays (20A, 10A) | Signal Repeater | Hub Required (Compatible with SmartThings and Hubitat)",,,ZEN17,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
,Living Room,IOT;Home Assistant; Z-Wave,1,Zooz Motion Sensor,"Zooz Z-Wave Plus S2 Motion Sensor ZSE18 with Magnetic Mount, Works with Vera and SmartThings",,,ZSE18,Zooz,,Amazon,29.95,10/15/2021,,,,,,,
,Office,IOT;Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
,Office,IOT; Home Assistant; Z-Wave,1,Zooz 110v Power Switch,"Zooz Z-Wave Plus Power Switch ZEN15 for 110V AC Units, Sump Pumps, Humidifiers, and More",,,ZEN15,Zooz,,Amazon,39.95,10/13/2021,,,,,,,
,Downstairs,IOT;Home Assistant; Z-Wave,1,Ecolink Z-Wave PIR Motion Sensor,"Ecolink Z-Wave PIR Motion Detector Pet Immune, White (PIRZWAVE2.5-ECO)",,,PIRZWAVE2.5-ECO,Ecolink,,Amazon,35.58,10/21/2020,,,,,,,
,Entry,IOT;Home Assistant; Z-Wave,1,Yale Security Touchscreen Deadbolt,"Yale Security YRD226-ZW2-619 YRD226ZW2619 Touchscreen Deadbolt, Satin Nickel",,,YRD226ZW2619,Yale,,Amazon,120.39,10/14/2020,,,,,,,
,Kitchen,IOT;Home Assistant; Z-Wave,1,Smart Rocker Light Dimmer,"UltraPro Z-Wave Smart Rocker Light Dimmer with QuickFit and SimpleWire, 3-Way Ready, Compatible with Alexa, Google Assistant, ZWave Hub Required, Repeater/Range Extender, White Paddle Only, 39351",,,39351,Honeywell,,Amazon,65.98,09/30/0202,,,,,,,
Expand All @@ -29,21 +30,26 @@ func (a *app) SetupDemo() error {
Password: "demo",
}

// First check if we've already setup a demo user and skip if so
log.Debug().Msg("Checking if demo user already exists")
_, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
if err == nil {
log.Info().Msg("Demo user already exists, skipping setup")
// If demo user already exists, skip all demo seeding tasks
if a.services.User.ExistsByEmail(ctx, registration.Email) {
log.Info().Msg("Demo user already exists; skipping demo seeding")
return nil
}

log.Debug().Msg("Demo user does not exist, setting up demo")
_, err = a.services.User.RegisterUser(ctx, registration)
// Otherwise, register the demo user
log.Debug().Msg("Registering demo user")
_, err := a.services.User.RegisterUser(ctx, registration)
if err != nil {
if ent.IsConstraintError(err) {
// Concurrent creation race: treat as exists and skip
log.Info().Msg("Demo user concurrently created; skipping seeding")
return nil
}
log.Err(err).Msg("Failed to register demo user")
return errors.New("failed to setup demo")
}

// Login the demo user to get a token
token, err := a.services.User.Login(ctx, registration.Email, registration.Password, false)
if err != nil {
log.Err(err).Msg("Failed to login demo user")
Expand All @@ -55,7 +61,7 @@ func (a *app) SetupDemo() error {
return errors.New("failed to setup demo")
}

_, err = a.services.Items.CsvImport(ctx, self.GroupID, strings.NewReader(csvText))
_, err = a.services.Items.CsvImport(ctx, self.DefaultGroupID, strings.NewReader(csvText))
if err != nil {
log.Err(err).Msg("Failed to import CSV")
return errors.New("failed to setup demo")
Expand Down
12 changes: 6 additions & 6 deletions backend/app/api/handlers/v1/v1_ctrl_actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,14 +119,14 @@ func (ctrl *V1Controller) HandleWipeInventory() errchain.HandlerFunc {
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 {
Expand All @@ -137,13 +137,13 @@ func (ctrl *V1Controller) HandleWipeInventory() errchain.HandlerFunc {
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 {
Expand All @@ -153,7 +153,7 @@ func (ctrl *V1Controller) HandleWipeInventory() errchain.HandlerFunc {
ctrl.bus.Publish(eventbus.EventLocationMutation, eventbus.GroupMutationEvent{GID: ctx.GID})
}
}

return server.JSON(w, http.StatusOK, ActionAmountResult{Completed: totalCompleted})
}
}
191 changes: 191 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_group.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package v1

import (
"errors"
"net/http"
"time"

"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/hay-kot/httpkit/errchain"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
Expand All @@ -22,6 +25,15 @@ type (
ExpiresAt time.Time `json:"expiresAt"`
Uses int `json:"uses"`
}

GroupMemberAdd struct {
UserID uuid.UUID `json:"userId" validate:"required"`
}

GroupAcceptInvitationResponse struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
}
)

// HandleGroupGet godoc
Expand Down Expand Up @@ -95,3 +107,182 @@ func (ctrl *V1Controller) HandleGroupInvitationsCreate() errchain.HandlerFunc {

return adapters.Action(fn, http.StatusCreated)
}

// HandleGroupsGetAll godoc
//
// @Summary Get All Groups
// @Tags Group
// @Produce json
// @Success 200 {object} []repo.Group
// @Router /v1/groups [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupsGetAll() errchain.HandlerFunc {
fn := func(r *http.Request) ([]repo.Group, error) {
auth := services.NewContext(r.Context())
return ctrl.repo.Groups.GetAllGroups(auth, auth.UID)
}

return adapters.Command(fn, http.StatusOK)
}

// HandleGroupCreate godoc
//
// @Summary Create Group
// @Tags Group
// @Produce json
// @Param name body string true "Group Name"
// @Success 201 {object} repo.Group
// @Router /v1/groups [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupCreate() errchain.HandlerFunc {
type CreateRequest struct {
Name string `json:"name" validate:"required"`
}

fn := func(r *http.Request, body CreateRequest) (repo.Group, error) {
auth := services.NewContext(r.Context())
return ctrl.svc.Group.CreateGroup(auth, body.Name)
}

return adapters.Action(fn, http.StatusCreated)
}

// HandleGroupDelete godoc
//
// @Summary Delete Group
// @Tags Group
// @Produce json
// @Success 204
// @Router /v1/groups/{id} [Delete]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupDelete() errchain.HandlerFunc {
fn := func(r *http.Request) (any, error) {
auth := services.NewContext(r.Context())
err := ctrl.svc.Group.DeleteGroup(auth)
return nil, err
}

return adapters.Command(fn, http.StatusNoContent)
}

// HandleGroupInvitationsGetAll godoc
//
// @Summary Get All Group Invitations
// @Tags Group
// @Produce json
// @Success 200 {object} []repo.GroupInvitation
// @Router /v1/groups/invitations [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupInvitationsGetAll() errchain.HandlerFunc {
fn := func(r *http.Request) ([]repo.GroupInvitation, error) {
auth := services.NewContext(r.Context())
return ctrl.repo.Groups.InvitationGetAll(auth, auth.GID)
}

return adapters.Command(fn, http.StatusOK)
}

// HandleGroupMembersGetAll godoc
//
// @Summary Get All Group Members
// @Tags Group
// @Produce json
// @Success 200 {object} []repo.UserOut
// @Router /v1/groups/{id}/members [Get]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupMembersGetAll() errchain.HandlerFunc {
fn := func(r *http.Request) ([]repo.UserSummary, error) {
auth := services.NewContext(r.Context())
return ctrl.repo.Users.GetUsersByGroupID(auth, auth.GID)
}

return adapters.Command(fn, http.StatusOK)
}

// HandleGroupMemberAdd godoc
//
// @Summary Add User to Group
// @Tags Group
// @Produce json
// @Param payload body GroupMemberAdd true "User ID"
// @Success 204
// @Router /v1/groups/{id}/members [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupMemberAdd() errchain.HandlerFunc {
fn := func(r *http.Request, body GroupMemberAdd) (any, error) {
auth := services.NewContext(r.Context())
err := ctrl.svc.Group.AddMember(auth, body.UserID)
return nil, err
}

return adapters.Action(fn, http.StatusNoContent)
}

// HandleGroupMemberRemove godoc
//
// @Summary Remove User from Group
// @Tags Group
// @Produce json
// @Param user_id path string true "User ID"
// @Success 204
// @Router /v1/groups/{id}/members/{user_id} [Delete]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupMemberRemove() errchain.HandlerFunc {
fn := func(r *http.Request, userID uuid.UUID) (any, error) {
auth := services.NewContext(r.Context())
err := ctrl.svc.Group.RemoveMember(auth, userID)
return nil, err
}

return adapters.CommandID("user_id", fn, http.StatusNoContent)
}

// HandleGroupInvitationsDelete godoc
//
// @Summary Delete Group Invitation
// @Tags Group
// @Produce json
// @Param id path string true "Invitation ID"
// @Success 204
// @Router /v1/groups/invitations/{id} [Delete]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupInvitationsDelete() errchain.HandlerFunc {
fn := func(r *http.Request, id uuid.UUID) (any, error) {
auth := services.NewContext(r.Context())
err := ctrl.svc.Group.DeleteInvitation(auth, id)
return nil, err
}

return adapters.CommandID("id", fn, http.StatusNoContent)
}

// HandleGroupInvitationsAccept godoc
//
// @Summary Accept Group Invitation
// @Tags Group
// @Produce json
// @Param id path string true "Invitation Token"
// @Success 200 {object} GroupAcceptInvitationResponse
// @Router /v1/groups/invitations/{id} [Post]
// @Security Bearer
func (ctrl *V1Controller) HandleGroupInvitationsAccept() errchain.HandlerFunc {
fn := func(r *http.Request) (GroupAcceptInvitationResponse, error) {
token := chi.URLParam(r, "id")
if token == "" {
return GroupAcceptInvitationResponse{}, validate.NewRequestError(errors.New("token is required"), http.StatusBadRequest)
}

auth := services.NewContext(r.Context())
group, err := ctrl.svc.Group.AcceptInvitation(auth, token)
if err != nil {
if errors.Is(err, errors.New("user already a member of this group")) {
return GroupAcceptInvitationResponse{}, validate.NewRequestError(err, http.StatusBadRequest)
}
return GroupAcceptInvitationResponse{}, err
}

return GroupAcceptInvitationResponse{ID: group.ID, Name: group.Name}, nil
}

return adapters.Command(fn, http.StatusOK)
}
4 changes: 2 additions & 2 deletions backend/app/api/handlers/v1/v1_ctrl_items.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,9 +337,9 @@ func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
return validate.NewRequestError(err, http.StatusInternalServerError)
}

user := services.UseUserCtx(r.Context())
tenant := services.UseTenantCtx(r.Context())

_, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, file)
_, err = ctrl.svc.Items.CsvImport(r.Context(), tenant, file)
if err != nil {
log.Err(err).Msg("failed to import items")
return validate.NewRequestError(err, http.StatusInternalServerError)
Expand Down
7 changes: 4 additions & 3 deletions backend/app/api/handlers/v1/v1_ctrl_reporting.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
package v1

import (
"net/http"

"github.com/hay-kot/httpkit/errchain"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"net/http"
)

// HandleBillOfMaterialsExport godoc
Expand All @@ -16,9 +17,9 @@ import (
// @Security Bearer
func (ctrl *V1Controller) HandleBillOfMaterialsExport() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
actor := services.UseUserCtx(r.Context())
tenant := services.UseTenantCtx(r.Context())

csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), actor.GroupID)
csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), tenant)
if err != nil {
return err
}
Expand Down
14 changes: 7 additions & 7 deletions backend/app/api/handlers/v1/v1_ctrl_user.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,13 +15,13 @@ import (

// HandleUserRegistration godoc
//
// @Summary Register New User
// @Tags User
// @Produce json
// @Param payload body services.UserRegistration true "User Data"
// @Success 204
// @Failure 403 {string} string "Local login is not enabled"
// @Router /v1/users/register [Post]
// @Summary Register New User
// @Tags User
// @Produce json
// @Param payload body services.UserRegistration true "User Data"
// @Success 204
// @Failure 403 {string} string "Local login is not enabled"
// @Router /v1/users/register [Post]
func (ctrl *V1Controller) HandleUserRegistration() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
// Forbidden if local login is not enabled
Expand Down
Loading
Loading