Compare commits

..

7 Commits

Author SHA1 Message Date
Phil
9bc6b4519c Fix duplicated language names 2025-12-28 21:45:57 +00:00
copilot-swe-agent[bot]
6f77eae638 fix: Add region qualifiers to Portuguese and Chinese variants for clarity
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-28 21:43:52 +00:00
Phil
6926aabd62 Remove redundant imports 2025-12-28 21:31:52 +00:00
copilot-swe-agent[bot]
b735ad12fd chore: Remove pycache and update gitignore
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-28 21:27:31 +00:00
copilot-swe-agent[bot]
f3e817e139 fix: Address feedback - add Weblate API, native names, validation, and completion threshold
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-28 21:27:06 +00:00
copilot-swe-agent[bot]
153ecd1094 feat: Add automated workflow to update language names in en.json
Co-authored-by: katosdev <7927609+katosdev@users.noreply.github.com>
2025-12-28 21:14:49 +00:00
copilot-swe-agent[bot]
0be54da9cf Initial plan 2025-12-28 21:09:09 +00:00
66 changed files with 1171 additions and 3481 deletions

349
.github/scripts/update_language_names.py vendored Executable file
View 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()

View 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"

View File

@@ -0,0 +1,2 @@
babel
requests

2
.gitignore vendored
View File

@@ -67,3 +67,5 @@ frontend/test-results/
frontend/playwright-report/
frontend/blob-report/
frontend/playwright/.cache/
__pycache__/
*.pyc

View File

@@ -8,14 +8,13 @@ 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,,,,,,,
@@ -30,26 +29,21 @@ func (a *app) SetupDemo() error {
Password: "demo",
}
// 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")
// 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")
return nil
}
// Otherwise, register the demo user
log.Debug().Msg("Registering demo user")
_, err := a.services.User.RegisterUser(ctx, registration)
log.Debug().Msg("Demo user does not exist, setting up demo")
_, 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")
@@ -61,7 +55,7 @@ func (a *app) SetupDemo() error {
return errors.New("failed to setup demo")
}
_, err = a.services.Items.CsvImport(ctx, self.DefaultGroupID, strings.NewReader(csvText))
_, err = a.services.Items.CsvImport(ctx, self.GroupID, strings.NewReader(csvText))
if err != nil {
log.Err(err).Msg("Failed to import CSV")
return errors.New("failed to setup demo")

View File

@@ -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 {
@@ -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 {
@@ -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})
}
}

View File

@@ -4,7 +4,6 @@ import (
"net/http"
"time"
"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"
@@ -23,10 +22,6 @@ type (
ExpiresAt time.Time `json:"expiresAt"`
Uses int `json:"uses"`
}
GroupMemberAdd struct {
UserID uuid.UUID `json:"userId" validate:"required"`
}
)
// HandleGroupGet godoc
@@ -100,132 +95,3 @@ 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)
}
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/{id} [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.UserOut, 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)
}

View File

@@ -337,9 +337,9 @@ func (ctrl *V1Controller) HandleItemsImport() errchain.HandlerFunc {
return validate.NewRequestError(err, http.StatusInternalServerError)
}
tenant := services.UseTenantCtx(r.Context())
user := services.UseUserCtx(r.Context())
_, err = ctrl.svc.Items.CsvImport(r.Context(), tenant, file)
_, err = ctrl.svc.Items.CsvImport(r.Context(), user.GroupID, file)
if err != nil {
log.Err(err).Msg("failed to import items")
return validate.NewRequestError(err, http.StatusInternalServerError)

View File

@@ -1,10 +1,9 @@
package v1
import (
"net/http"
"github.com/hay-kot/httpkit/errchain"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"net/http"
)
// HandleBillOfMaterialsExport godoc
@@ -17,9 +16,9 @@ import (
// @Security Bearer
func (ctrl *V1Controller) HandleBillOfMaterialsExport() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
tenant := services.UseTenantCtx(r.Context())
actor := services.UseUserCtx(r.Context())
csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), tenant)
csv, err := ctrl.svc.Items.ExportBillOfMaterialsCSV(r.Context(), actor.GroupID)
if err != nil {
return err
}

View File

@@ -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

View File

@@ -7,7 +7,6 @@ import (
"net/url"
"strings"
"github.com/google/uuid"
"github.com/hay-kot/httpkit/errchain"
v1 "github.com/sysadminsmedia/homebox/backend/app/api/handlers/v1"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
@@ -153,48 +152,3 @@ func (a *app) mwAuthToken(next errchain.Handler) errchain.Handler {
return next.ServeHTTP(w, r)
})
}
// mwTenant is a middleware that will parse the X-Tenant header and validate the user has access
// to the requested tenant. If no header is provided, the user's default group is used.
//
// WARNING: This middleware _MUST_ be called after mwAuthToken
func (a *app) mwTenant(next errchain.Handler) errchain.Handler {
return errchain.HandlerFunc(func(w http.ResponseWriter, r *http.Request) error {
ctx := r.Context()
// Get the user from context (set by mwAuthToken)
user := services.UseUserCtx(ctx)
if user == nil {
return validate.NewRequestError(errors.New("user context not found"), http.StatusInternalServerError)
}
tenantID := user.DefaultGroupID
// Check for X-Tenant header
if tenantHeader := r.Header.Get("X-Tenant"); tenantHeader != "" {
parsedTenantID, err := uuid.Parse(tenantHeader)
if err != nil {
return validate.NewRequestError(errors.New("invalid X-Tenant header format"), http.StatusBadRequest)
}
// Validate user has access to the requested tenant
hasAccess := false
for _, gid := range user.GroupIDs {
if gid == parsedTenantID {
hasAccess = true
break
}
}
if !hasAccess {
return validate.NewRequestError(errors.New("user does not have access to the requested tenant"), http.StatusForbidden)
}
tenantID = parsedTenantID
}
// Set the tenant in context
r = r.WithContext(services.SetTenantCtx(ctx, tenantID))
return next.ServeHTTP(w, r)
})
}

View File

@@ -82,13 +82,10 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
userMW := []errchain.Middleware{
a.mwAuthToken,
a.mwTenant,
a.mwRoles(RoleModeOr, authroles.RoleUser.String()),
}
r.Get("/ws/events", chain.ToHandlerFunc(v1Ctrl.HandleCacheWS(), userMW...))
// User management endpoints
r.Get("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelf(), userMW...))
r.Put("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfUpdate(), userMW...))
r.Delete("/users/self", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfDelete(), userMW...))
@@ -96,25 +93,16 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Get("/users/refresh", chain.ToHandlerFunc(v1Ctrl.HandleAuthRefresh(), userMW...))
r.Put("/users/self/change-password", chain.ToHandlerFunc(v1Ctrl.HandleUserSelfChangePassword(), userMW...))
// Group management endpoints
r.Get("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupsGetAll(), userMW...))
r.Get("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupGet(), userMW...))
r.Post("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupCreate(), userMW...))
r.Put("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupUpdate(), userMW...))
r.Delete("/groups/{id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupDelete(), userMW...))
r.Get("/groups/{id}/members", chain.ToHandlerFunc(v1Ctrl.HandleGroupMembersGetAll(), userMW...))
r.Post("/groups/{id}/members", chain.ToHandlerFunc(v1Ctrl.HandleGroupMemberAdd(), userMW...))
r.Delete("/groups/{id}/members/{user_id}", chain.ToHandlerFunc(v1Ctrl.HandleGroupMemberRemove(), userMW...))
r.Get("/groups/invitations", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsGetAll(), userMW...))
r.Post("/groups/invitations", chain.ToHandlerFunc(v1Ctrl.HandleGroupInvitationsCreate(), userMW...))
r.Get("/groups/statistics", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatistics(), userMW...))
r.Get("/groups/statistics/purchase-price", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsPriceOverTime(), userMW...))
r.Get("/groups/statistics/locations", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLocations(), userMW...))
r.Get("/groups/statistics/labels", chain.ToHandlerFunc(v1Ctrl.HandleGroupStatisticsLabels(), userMW...))
// Action endpoints
// TODO: I don't like /groups being the URL for users
r.Get("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupGet(), userMW...))
r.Put("/groups", chain.ToHandlerFunc(v1Ctrl.HandleGroupUpdate(), userMW...))
r.Post("/actions/ensure-asset-ids", chain.ToHandlerFunc(v1Ctrl.HandleEnsureAssetID(), userMW...))
r.Post("/actions/zero-item-time-fields", chain.ToHandlerFunc(v1Ctrl.HandleItemDateZeroOut(), userMW...))
r.Post("/actions/ensure-import-refs", chain.ToHandlerFunc(v1Ctrl.HandleEnsureImportRefs(), userMW...))
@@ -122,7 +110,6 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Post("/actions/create-missing-thumbnails", chain.ToHandlerFunc(v1Ctrl.HandleCreateMissingThumbnails(), userMW...))
r.Post("/actions/wipe-inventory", chain.ToHandlerFunc(v1Ctrl.HandleWipeInventory(), userMW...))
// Location endpoints
r.Get("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationGetAll(), userMW...))
r.Post("/locations", chain.ToHandlerFunc(v1Ctrl.HandleLocationCreate(), userMW...))
r.Get("/locations/tree", chain.ToHandlerFunc(v1Ctrl.HandleLocationTreeQuery(), userMW...))
@@ -130,14 +117,12 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Put("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationUpdate(), userMW...))
r.Delete("/locations/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLocationDelete(), userMW...))
// Labels (tags) endpoints
r.Get("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsGetAll(), userMW...))
r.Post("/labels", chain.ToHandlerFunc(v1Ctrl.HandleLabelsCreate(), userMW...))
r.Get("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelGet(), userMW...))
r.Put("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelUpdate(), userMW...))
r.Delete("/labels/{id}", chain.ToHandlerFunc(v1Ctrl.HandleLabelDelete(), userMW...))
// Item endpoints
r.Get("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsGetAll(), userMW...))
r.Post("/items", chain.ToHandlerFunc(v1Ctrl.HandleItemsCreate(), userMW...))
r.Post("/items/import", chain.ToHandlerFunc(v1Ctrl.HandleItemsImport(), userMW...))
@@ -152,12 +137,10 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Delete("/items/{id}", chain.ToHandlerFunc(v1Ctrl.HandleItemDelete(), userMW...))
r.Post("/items/{id}/duplicate", chain.ToHandlerFunc(v1Ctrl.HandleItemDuplicate(), userMW...))
// Item attachment endpoints
r.Post("/items/{id}/attachments", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentCreate(), userMW...))
r.Put("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentUpdate(), userMW...))
r.Delete("/items/{id}/attachments/{attachment_id}", chain.ToHandlerFunc(v1Ctrl.HandleItemAttachmentDelete(), userMW...))
// Item maintenance endpoints
r.Get("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceLogGet(), userMW...))
r.Post("/items/{id}/maintenance", chain.ToHandlerFunc(v1Ctrl.HandleMaintenanceEntryCreate(), userMW...))

View File

@@ -243,15 +243,12 @@ const docTemplate = `{
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.Group"
}
"$ref": "#/definitions/repo.Group"
}
}
}
@@ -291,31 +288,6 @@ const docTemplate = `{
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.GroupInvitation"
}
}
}
}
},
"post": {
"security": [
{
@@ -466,147 +438,6 @@ const docTemplate = `{
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Create Group",
"parameters": [
{
"description": "Group Name",
"name": "name",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.UserOut"
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"parameters": [
{
"description": "User ID",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.GroupMemberAdd"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3698,10 +3529,6 @@ const docTemplate = `{
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3762,12 +3589,13 @@ const docTemplate = `{
"$ref": "#/definitions/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/definitions/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/definitions/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -3861,23 +3689,6 @@ const docTemplate = `{
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/definitions/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5095,17 +4906,14 @@ const docTemplate = `{
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5326,17 +5134,6 @@ const docTemplate = `{
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -242,17 +242,14 @@
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.Group"
}
"$ref": "#/components/schemas/repo.Group"
}
}
}
@@ -295,32 +292,6 @@
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.GroupInvitation"
}
}
}
}
}
}
},
"post": {
"security": [
{
@@ -480,142 +451,6 @@
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Create Group",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
},
"description": "Group Name",
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/repo.Group"
}
}
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.UserOut"
}
}
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/v1.GroupMemberAdd"
}
}
},
"description": "User ID",
"required": true
},
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3892,10 +3727,6 @@
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3956,12 +3787,13 @@
"$ref": "#/components/schemas/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/components/schemas/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/components/schemas/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -4055,23 +3887,6 @@
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/components/schemas/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5289,17 +5104,14 @@
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5520,17 +5332,6 @@
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -142,16 +142,14 @@ paths:
- Bearer: []
tags:
- Group
summary: Get All Groups
summary: Get Group
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.Group"
$ref: "#/components/schemas/repo.Group"
put:
security:
- Bearer: []
@@ -173,21 +171,6 @@ paths:
schema:
$ref: "#/components/schemas/repo.Group"
/v1/groups/invitations:
get:
security:
- Bearer: []
tags:
- Group
summary: Get All Group Invitations
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.GroupInvitation"
post:
security:
- Bearer: []
@@ -279,85 +262,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/repo.ValueOverTime"
"/v1/groups/{id}":
post:
security:
- Bearer: []
tags:
- Group
summary: Create Group
requestBody:
content:
application/json:
schema:
type: string
description: Group Name
required: true
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/repo.Group"
delete:
security:
- Bearer: []
tags:
- Group
summary: Delete Group
responses:
"204":
description: No Content
"/v1/groups/{id}/members":
get:
security:
- Bearer: []
tags:
- Group
summary: Get All Group Members
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.UserOut"
post:
security:
- Bearer: []
tags:
- Group
summary: Add User to Group
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/v1.GroupMemberAdd"
description: User ID
required: true
responses:
"204":
description: No Content
"/v1/groups/{id}/members/{user_id}":
delete:
security:
- Bearer: []
tags:
- Group
summary: Remove User from Group
parameters:
- description: User ID
name: user_id
in: path
required: true
schema:
type: string
responses:
"204":
description: No Content
/v1/items:
get:
security:
@@ -2418,9 +2322,6 @@ components:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
default_group_id:
description: DefaultGroupID holds the value of the "default_group_id" field.
type: string
edges:
description: >-
Edges holds the relations/edges for other nodes in the graph.
@@ -2464,11 +2365,10 @@ components:
type: array
items:
$ref: "#/components/schemas/ent.AuthTokens"
groups:
description: Groups holds the value of the groups edge.
type: array
items:
$ref: "#/components/schemas/ent.Group"
group:
description: Group holds the value of the group edge.
allOf:
- $ref: "#/components/schemas/ent.Group"
notifiers:
description: Notifiers holds the value of the notifiers edge.
type: array
@@ -2531,17 +2431,6 @@ components:
type: string
updatedAt:
type: string
repo.GroupInvitation:
type: object
properties:
expiresAt:
type: string
group:
$ref: "#/components/schemas/repo.Group"
id:
type: string
uses:
type: integer
repo.GroupStatistics:
type: object
properties:
@@ -3375,14 +3264,12 @@ components:
repo.UserOut:
type: object
properties:
defaultGroupId:
type: string
email:
type: string
groupIds:
type: array
items:
type: string
groupId:
type: string
groupName:
type: string
id:
type: string
isOwner:
@@ -3526,13 +3413,6 @@ components:
type: integer
maximum: 100
minimum: 1
v1.GroupMemberAdd:
type: object
required:
- userId
properties:
userId:
type: string
v1.ItemAttachmentToken:
type: object
properties:

View File

@@ -241,15 +241,12 @@
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.Group"
}
"$ref": "#/definitions/repo.Group"
}
}
}
@@ -289,31 +286,6 @@
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.GroupInvitation"
}
}
}
}
},
"post": {
"security": [
{
@@ -464,147 +436,6 @@
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Create Group",
"parameters": [
{
"description": "Group Name",
"name": "name",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.UserOut"
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"parameters": [
{
"description": "User ID",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.GroupMemberAdd"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3696,10 +3527,6 @@
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3760,12 +3587,13 @@
"$ref": "#/definitions/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/definitions/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/definitions/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -3859,23 +3687,6 @@
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/definitions/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5093,17 +4904,14 @@
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5324,17 +5132,6 @@
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -719,9 +719,6 @@ definitions:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
default_group_id:
description: DefaultGroupID holds the value of the "default_group_id" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.UserEdges'
@@ -764,11 +761,10 @@ definitions:
items:
$ref: '#/definitions/ent.AuthTokens'
type: array
groups:
description: Groups holds the value of the groups edge.
items:
$ref: '#/definitions/ent.Group'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
notifiers:
description: Notifiers holds the value of the notifiers edge.
items:
@@ -832,17 +828,6 @@ definitions:
updatedAt:
type: string
type: object
repo.GroupInvitation:
properties:
expiresAt:
type: string
group:
$ref: '#/definitions/repo.Group'
id:
type: string
uses:
type: integer
type: object
repo.GroupStatistics:
properties:
totalItemPrice:
@@ -1675,14 +1660,12 @@ definitions:
type: object
repo.UserOut:
properties:
defaultGroupId:
type: string
email:
type: string
groupIds:
items:
type: string
type: array
groupId:
type: string
groupName:
type: string
id:
type: string
isOwner:
@@ -1827,13 +1810,6 @@ definitions:
required:
- uses
type: object
v1.GroupMemberAdd:
properties:
userId:
type: string
required:
- userId
type: object
v1.ItemAttachmentToken:
properties:
token:
@@ -2056,12 +2032,10 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.Group'
type: array
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get All Groups
summary: Get Group
tags:
- Group
put:
@@ -2084,106 +2058,7 @@ paths:
summary: Update Group
tags:
- Group
/v1/groups/{id}:
delete:
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Delete Group
tags:
- Group
post:
parameters:
- description: Group Name
in: body
name: name
required: true
schema:
type: string
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Create Group
tags:
- Group
/v1/groups/{id}/members:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.UserOut'
type: array
security:
- Bearer: []
summary: Get All Group Members
tags:
- Group
post:
parameters:
- description: User ID
in: body
name: payload
required: true
schema:
$ref: '#/definitions/v1.GroupMemberAdd'
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Add User to Group
tags:
- Group
/v1/groups/{id}/members/{user_id}:
delete:
parameters:
- description: User ID
in: path
name: user_id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Remove User from Group
tags:
- Group
/v1/groups/invitations:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.GroupInvitation'
type: array
security:
- Bearer: []
summary: Get All Group Invitations
tags:
- Group
post:
parameters:
- description: User Data

View File

@@ -325,8 +325,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-sqlite3 v1.14.32 h1:JD12Ag3oLy1zQA+BNn74xRgaBbdhbNIDYvQUEuuErjs=
github.com/mattn/go-sqlite3 v1.14.32/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
@@ -349,8 +347,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI=
github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
@@ -393,10 +389,6 @@ github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAX
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -14,7 +14,6 @@ type contextKeys struct {
var (
ContextUser = &contextKeys{name: "User"}
ContextUserToken = &contextKeys{name: "UserToken"}
ContextTenant = &contextKeys{name: "Tenant"}
)
type Context struct {
@@ -34,14 +33,10 @@ type Context struct {
// This extracts the users from the context and embeds it into the ServiceContext struct
func NewContext(ctx context.Context) Context {
user := UseUserCtx(ctx)
gid := UseTenantCtx(ctx)
if gid == uuid.Nil && user != nil {
gid = user.DefaultGroupID
}
return Context{
Context: ctx,
UID: user.ID,
GID: gid,
GID: user.GroupID,
User: user,
}
}
@@ -69,17 +64,3 @@ func UseTokenCtx(ctx context.Context) string {
}
return ""
}
// UseTenantCtx is a helper function that returns the tenant group ID from the context.
// Returns uuid.Nil if not set.
func UseTenantCtx(ctx context.Context) uuid.UUID {
if val := ctx.Value(ContextTenant); val != nil {
return val.(uuid.UUID)
}
return uuid.Nil
}
// SetTenantCtx is a helper function that sets the ContextTenant in the context.
func SetTenantCtx(ctx context.Context, tenantID uuid.UUID) context.Context {
return context.WithValue(ctx, ContextTenant, tenantID)
}

View File

@@ -2,13 +2,11 @@ package services
import (
"context"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"log"
"os"
"testing"
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
_ "github.com/mattn/go-sqlite3"
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
"github.com/sysadminsmedia/homebox/backend/internal/core/services/reporting/eventbus"
@@ -35,18 +33,18 @@ func bootstrap() {
ctx = context.Background()
)
tGroup, err = tRepos.Groups.GroupCreate(ctx, "test-group", uuid.Nil)
tGroup, err = tRepos.Groups.GroupCreate(ctx, "test-group")
if err != nil {
log.Fatal(err)
}
password := fk.Str(10)
tUser, err = tRepos.Users.Create(ctx, repo.UserCreate{
Name: fk.Str(10),
Email: fk.Email(),
Password: &password,
IsSuperuser: fk.Bool(),
DefaultGroupID: tGroup.ID,
Name: fk.Str(10),
Email: fk.Email(),
Password: &password,
IsSuperuser: fk.Bool(),
GroupID: tGroup.ID,
})
if err != nil {
log.Fatal(err)

View File

@@ -4,7 +4,6 @@ import (
"errors"
"time"
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
"github.com/sysadminsmedia/homebox/backend/pkgs/hasher"
)
@@ -15,7 +14,7 @@ type GroupService struct {
func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.Group, error) {
if data.Name == "" {
return repo.Group{}, errors.New("group name cannot be empty")
data.Name = ctx.User.GroupName
}
if data.Currency == "" {
@@ -25,18 +24,6 @@ func (svc *GroupService) UpdateGroup(ctx Context, data repo.GroupUpdate) (repo.G
return svc.repos.Groups.GroupUpdate(ctx.Context, ctx.GID, data)
}
func (svc *GroupService) CreateGroup(ctx Context, name string) (repo.Group, error) {
if name == "" {
return repo.Group{}, errors.New("group name cannot be empty")
}
return svc.repos.Groups.GroupCreate(ctx.Context, name, ctx.UID)
}
func (svc *GroupService) DeleteGroup(ctx Context) error {
return svc.repos.Groups.GroupDelete(ctx.Context, ctx.GID)
}
func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Time) (string, error) {
token := hasher.GenerateToken()
@@ -51,19 +38,3 @@ func (svc *GroupService) NewInvitation(ctx Context, uses int, expiresAt time.Tim
return token.Raw, nil
}
func (svc *GroupService) AddMember(ctx Context, userID uuid.UUID) error {
if userID == uuid.Nil {
return errors.New("user ID cannot be empty")
}
return svc.repos.Groups.AddMember(ctx.Context, ctx.GID, userID)
}
func (svc *GroupService) RemoveMember(ctx Context, userID uuid.UUID) error {
if userID == uuid.Nil {
return errors.New("user ID cannot be empty")
}
return svc.repos.Groups.RemoveMember(ctx.Context, ctx.GID, userID)
}

View File

@@ -64,7 +64,7 @@ func (svc *UserService) RegisterUser(ctx context.Context, data UserRegistration)
case "":
log.Debug().Msg("creating new group")
creatingGroup = true
group, err = svc.repos.Groups.GroupCreate(ctx, "Home", uuid.Nil)
group, err = svc.repos.Groups.GroupCreate(ctx, "Home")
if err != nil {
log.Err(err).Msg("Failed to create group")
return repo.UserOut{}, err
@@ -81,12 +81,12 @@ func (svc *UserService) RegisterUser(ctx context.Context, data UserRegistration)
hashed, _ := hasher.HashPassword(data.Password)
usrCreate := repo.UserCreate{
Name: data.Name,
Email: data.Email,
Password: &hashed,
IsSuperuser: false,
DefaultGroupID: group.ID,
IsOwner: creatingGroup,
Name: data.Name,
Email: data.Email,
Password: &hashed,
IsSuperuser: false,
GroupID: group.ID,
IsOwner: creatingGroup,
}
usr, err := svc.repos.Users.Create(ctx, usrCreate)
@@ -99,7 +99,7 @@ func (svc *UserService) RegisterUser(ctx context.Context, data UserRegistration)
if creatingGroup {
log.Debug().Msg("creating default labels")
for _, label := range defaultLabels() {
_, err := svc.repos.Labels.Create(ctx, usr.DefaultGroupID, label)
_, err := svc.repos.Labels.Create(ctx, usr.GroupID, label)
if err != nil {
return repo.UserOut{}, err
}
@@ -107,7 +107,7 @@ func (svc *UserService) RegisterUser(ctx context.Context, data UserRegistration)
log.Debug().Msg("creating default locations")
for _, location := range defaultLocations() {
_, err := svc.repos.Locations.Create(ctx, usr.DefaultGroupID, location)
_, err := svc.repos.Locations.Create(ctx, usr.GroupID, location)
if err != nil {
return repo.UserOut{}, err
}
@@ -280,19 +280,19 @@ func (svc *UserService) LoginOIDC(ctx context.Context, issuer, subject, email, n
// registerOIDCUser creates a new user for OIDC authentication with issuer+subject identity.
func (svc *UserService) registerOIDCUser(ctx context.Context, issuer, subject, email, name string) (repo.UserOut, error) {
group, err := svc.repos.Groups.GroupCreate(ctx, "Home", uuid.Nil)
group, err := svc.repos.Groups.GroupCreate(ctx, "Home")
if err != nil {
log.Err(err).Msg("Failed to create group for OIDC user")
return repo.UserOut{}, err
}
usrCreate := repo.UserCreate{
Name: name,
Email: email,
Password: nil,
IsSuperuser: false,
DefaultGroupID: group.ID,
IsOwner: true,
Name: name,
Email: email,
Password: nil,
IsSuperuser: false,
GroupID: group.ID,
IsOwner: true,
}
entUser, err := svc.repos.Users.CreateWithOIDC(ctx, usrCreate, issuer, subject)
@@ -369,30 +369,3 @@ func (svc *UserService) ChangePassword(ctx Context, current string, new string)
return true
}
func (svc *UserService) EnsureUserPassword(ctx context.Context, email, password string) error {
usr, err := svc.repos.Users.GetOneEmailNoEdges(ctx, email)
if err != nil {
return err
}
match := false
if usr.PasswordHash != "" {
match, _ = hasher.CheckPasswordHash(password, usr.PasswordHash)
}
if !match {
hash, herr := hasher.HashPassword(password)
if herr != nil {
return herr
}
if cerr := svc.repos.Users.ChangePassword(ctx, usr.ID, hash); cerr != nil {
return cerr
}
}
return nil
}
// ExistsByEmail returns true if a user with the given email exists.
func (svc *UserService) ExistsByEmail(ctx context.Context, email string) bool {
_, err := svc.repos.Users.GetOneEmailNoEdges(ctx, email)
return err == nil
}

View File

@@ -909,7 +909,7 @@ func (c *GroupClient) QueryUsers(_m *Group) *UserQuery {
step := sqlgraph.NewStep(
sqlgraph.From(group.Table, group.FieldID, id),
sqlgraph.To(user.Table, user.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, group.UsersTable, group.UsersPrimaryKey...),
sqlgraph.Edge(sqlgraph.O2M, false, group.UsersTable, group.UsersColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
@@ -2711,15 +2711,15 @@ func (c *UserClient) GetX(ctx context.Context, id uuid.UUID) *User {
return obj
}
// QueryGroups queries the groups edge of a User.
func (c *UserClient) QueryGroups(_m *User) *GroupQuery {
// QueryGroup queries the group edge of a User.
func (c *UserClient) QueryGroup(_m *User) *GroupQuery {
query := (&GroupClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(user.Table, user.FieldID, id),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, user.GroupsTable, user.GroupsPrimaryKey...),
sqlgraph.Edge(sqlgraph.M2O, true, user.GroupTable, user.GroupColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil

View File

@@ -39,11 +39,13 @@ const (
EdgeItemTemplates = "item_templates"
// Table holds the table name of the group in the database.
Table = "groups"
// UsersTable is the table that holds the users relation/edge. The primary key declared below.
UsersTable = "user_groups"
// UsersTable is the table that holds the users relation/edge.
UsersTable = "users"
// UsersInverseTable is the table name for the User entity.
// It exists in this package in order to avoid circular dependency with the "user" package.
UsersInverseTable = "users"
// UsersColumn is the table column denoting the users relation/edge.
UsersColumn = "group_users"
// LocationsTable is the table that holds the locations relation/edge.
LocationsTable = "locations"
// LocationsInverseTable is the table name for the Location entity.
@@ -97,12 +99,6 @@ var Columns = []string{
FieldCurrency,
}
var (
// UsersPrimaryKey and UsersColumn2 are the table columns denoting the
// primary key for the users relation (M2M).
UsersPrimaryKey = []string{"user_id", "group_id"}
)
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
@@ -257,7 +253,7 @@ func newUsersStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(UsersInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, UsersTable, UsersPrimaryKey...),
sqlgraph.Edge(sqlgraph.O2M, false, UsersTable, UsersColumn),
)
}
func newLocationsStep() *sqlgraph.Step {

View File

@@ -291,7 +291,7 @@ func HasUsers() predicate.Group {
return predicate.Group(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, UsersTable, UsersPrimaryKey...),
sqlgraph.Edge(sqlgraph.O2M, false, UsersTable, UsersColumn),
)
sqlgraph.HasNeighbors(s, step)
})

View File

@@ -320,10 +320,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
}
if nodes := _c.mutation.UsersIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),

View File

@@ -88,7 +88,7 @@ func (_q *GroupQuery) QueryUsers() *UserQuery {
step := sqlgraph.NewStep(
sqlgraph.From(group.Table, group.FieldID, selector),
sqlgraph.To(user.Table, user.FieldID),
sqlgraph.Edge(sqlgraph.M2M, true, group.UsersTable, group.UsersPrimaryKey...),
sqlgraph.Edge(sqlgraph.O2M, false, group.UsersTable, group.UsersColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
@@ -671,63 +671,33 @@ func (_q *GroupQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Group,
}
func (_q *GroupQuery) loadUsers(ctx context.Context, query *UserQuery, nodes []*Group, init func(*Group), assign func(*Group, *User)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[uuid.UUID]*Group)
nids := make(map[uuid.UUID]map[*Group]struct{})
for i, node := range nodes {
edgeIDs[i] = node.ID
byID[node.ID] = node
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[uuid.UUID]*Group)
for i := range nodes {
fks = append(fks, nodes[i].ID)
nodeids[nodes[i].ID] = nodes[i]
if init != nil {
init(node)
init(nodes[i])
}
}
query.Where(func(s *sql.Selector) {
joinT := sql.Table(group.UsersTable)
s.Join(joinT).On(s.C(user.FieldID), joinT.C(group.UsersPrimaryKey[0]))
s.Where(sql.InValues(joinT.C(group.UsersPrimaryKey[1]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(group.UsersPrimaryKey[1]))
s.AppendSelect(columns...)
s.SetDistinct(false)
})
if err := query.prepareQuery(ctx); err != nil {
return err
}
qr := QuerierFunc(func(ctx context.Context, q Query) (Value, error) {
return query.sqlAll(ctx, func(_ context.Context, spec *sqlgraph.QuerySpec) {
assign := spec.Assign
values := spec.ScanValues
spec.ScanValues = func(columns []string) ([]any, error) {
values, err := values(columns[1:])
if err != nil {
return nil, err
}
return append([]any{new(uuid.UUID)}, values...), nil
}
spec.Assign = func(columns []string, values []any) error {
outValue := *values[0].(*uuid.UUID)
inValue := *values[1].(*uuid.UUID)
if nids[inValue] == nil {
nids[inValue] = map[*Group]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*User](ctx, query, qr, query.inters)
query.withFKs = true
query.Where(predicate.User(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(group.UsersColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nids[n.ID]
fk := n.group_users
if fk == nil {
return fmt.Errorf(`foreign-key "group_users" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected "users" node returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
return fmt.Errorf(`unexpected referenced foreign-key "group_users" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}

View File

@@ -396,10 +396,10 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if _u.mutation.UsersCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),
@@ -409,10 +409,10 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if nodes := _u.mutation.RemovedUsersIDs(); len(nodes) > 0 && !_u.mutation.UsersCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),
@@ -425,10 +425,10 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if nodes := _u.mutation.UsersIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),
@@ -1119,10 +1119,10 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
}
if _u.mutation.UsersCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),
@@ -1132,10 +1132,10 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
}
if nodes := _u.mutation.RemovedUsersIDs(); len(nodes) > 0 && !_u.mutation.UsersCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),
@@ -1148,10 +1148,10 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
}
if nodes := _u.mutation.UsersIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: true,
Rel: sqlgraph.O2M,
Inverse: false,
Table: group.UsersTable,
Columns: group.UsersPrimaryKey,
Columns: []string{group.UsersColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(user.FieldID, field.TypeUUID),

View File

@@ -468,13 +468,21 @@ var (
{Name: "activated_on", Type: field.TypeTime, Nullable: true},
{Name: "oidc_issuer", Type: field.TypeString, Nullable: true},
{Name: "oidc_subject", Type: field.TypeString, Nullable: true},
{Name: "default_group_id", Type: field.TypeUUID, Nullable: true},
{Name: "group_users", Type: field.TypeUUID},
}
// UsersTable holds the schema information for the "users" table.
UsersTable = &schema.Table{
Name: "users",
Columns: UsersColumns,
PrimaryKey: []*schema.Column{UsersColumns[0]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "users_groups_users",
Columns: []*schema.Column{UsersColumns[12]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.Cascade,
},
},
Indexes: []*schema.Index{
{
Name: "user_oidc_issuer_oidc_subject",
@@ -508,31 +516,6 @@ var (
},
},
}
// UserGroupsColumns holds the columns for the "user_groups" table.
UserGroupsColumns = []*schema.Column{
{Name: "user_id", Type: field.TypeUUID},
{Name: "group_id", Type: field.TypeUUID},
}
// UserGroupsTable holds the schema information for the "user_groups" table.
UserGroupsTable = &schema.Table{
Name: "user_groups",
Columns: UserGroupsColumns,
PrimaryKey: []*schema.Column{UserGroupsColumns[0], UserGroupsColumns[1]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "user_groups_user_id",
Columns: []*schema.Column{UserGroupsColumns[0]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.Cascade,
},
{
Symbol: "user_groups_group_id",
Columns: []*schema.Column{UserGroupsColumns[1]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.Cascade,
},
},
}
// Tables holds all the tables in the schema.
Tables = []*schema.Table{
AttachmentsTable,
@@ -550,7 +533,6 @@ var (
TemplateFieldsTable,
UsersTable,
LabelItemsTable,
UserGroupsTable,
}
)
@@ -573,8 +555,7 @@ func init() {
NotifiersTable.ForeignKeys[0].RefTable = GroupsTable
NotifiersTable.ForeignKeys[1].RefTable = UsersTable
TemplateFieldsTable.ForeignKeys[0].RefTable = ItemTemplatesTable
UsersTable.ForeignKeys[0].RefTable = GroupsTable
LabelItemsTable.ForeignKeys[0].RefTable = LabelsTable
LabelItemsTable.ForeignKeys[1].RefTable = ItemsTable
UserGroupsTable.ForeignKeys[0].RefTable = UsersTable
UserGroupsTable.ForeignKeys[1].RefTable = GroupsTable
}

View File

@@ -12583,11 +12583,9 @@ type UserMutation struct {
activated_on *time.Time
oidc_issuer *string
oidc_subject *string
default_group_id *uuid.UUID
clearedFields map[string]struct{}
groups map[uuid.UUID]struct{}
removedgroups map[uuid.UUID]struct{}
clearedgroups bool
group *uuid.UUID
clearedgroup bool
auth_tokens map[uuid.UUID]struct{}
removedauth_tokens map[uuid.UUID]struct{}
clearedauth_tokens bool
@@ -13151,107 +13149,43 @@ func (m *UserMutation) ResetOidcSubject() {
delete(m.clearedFields, user.FieldOidcSubject)
}
// SetDefaultGroupID sets the "default_group_id" field.
func (m *UserMutation) SetDefaultGroupID(u uuid.UUID) {
m.default_group_id = &u
// SetGroupID sets the "group" edge to the Group entity by id.
func (m *UserMutation) SetGroupID(id uuid.UUID) {
m.group = &id
}
// DefaultGroupID returns the value of the "default_group_id" field in the mutation.
func (m *UserMutation) DefaultGroupID() (r uuid.UUID, exists bool) {
v := m.default_group_id
if v == nil {
return
}
return *v, true
// ClearGroup clears the "group" edge to the Group entity.
func (m *UserMutation) ClearGroup() {
m.clearedgroup = true
}
// OldDefaultGroupID returns the old "default_group_id" field's value of the User entity.
// If the User object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UserMutation) OldDefaultGroupID(ctx context.Context) (v *uuid.UUID, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldDefaultGroupID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldDefaultGroupID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldDefaultGroupID: %w", err)
}
return oldValue.DefaultGroupID, nil
// GroupCleared reports if the "group" edge to the Group entity was cleared.
func (m *UserMutation) GroupCleared() bool {
return m.clearedgroup
}
// ClearDefaultGroupID clears the value of the "default_group_id" field.
func (m *UserMutation) ClearDefaultGroupID() {
m.default_group_id = nil
m.clearedFields[user.FieldDefaultGroupID] = struct{}{}
}
// DefaultGroupIDCleared returns if the "default_group_id" field was cleared in this mutation.
func (m *UserMutation) DefaultGroupIDCleared() bool {
_, ok := m.clearedFields[user.FieldDefaultGroupID]
return ok
}
// ResetDefaultGroupID resets all changes to the "default_group_id" field.
func (m *UserMutation) ResetDefaultGroupID() {
m.default_group_id = nil
delete(m.clearedFields, user.FieldDefaultGroupID)
}
// AddGroupIDs adds the "groups" edge to the Group entity by ids.
func (m *UserMutation) AddGroupIDs(ids ...uuid.UUID) {
if m.groups == nil {
m.groups = make(map[uuid.UUID]struct{})
}
for i := range ids {
m.groups[ids[i]] = struct{}{}
}
}
// ClearGroups clears the "groups" edge to the Group entity.
func (m *UserMutation) ClearGroups() {
m.clearedgroups = true
}
// GroupsCleared reports if the "groups" edge to the Group entity was cleared.
func (m *UserMutation) GroupsCleared() bool {
return m.clearedgroups
}
// RemoveGroupIDs removes the "groups" edge to the Group entity by IDs.
func (m *UserMutation) RemoveGroupIDs(ids ...uuid.UUID) {
if m.removedgroups == nil {
m.removedgroups = make(map[uuid.UUID]struct{})
}
for i := range ids {
delete(m.groups, ids[i])
m.removedgroups[ids[i]] = struct{}{}
}
}
// RemovedGroups returns the removed IDs of the "groups" edge to the Group entity.
func (m *UserMutation) RemovedGroupsIDs() (ids []uuid.UUID) {
for id := range m.removedgroups {
ids = append(ids, id)
// GroupID returns the "group" edge ID in the mutation.
func (m *UserMutation) GroupID() (id uuid.UUID, exists bool) {
if m.group != nil {
return *m.group, true
}
return
}
// GroupsIDs returns the "groups" edge IDs in the mutation.
func (m *UserMutation) GroupsIDs() (ids []uuid.UUID) {
for id := range m.groups {
ids = append(ids, id)
// GroupIDs returns the "group" edge IDs in the mutation.
// Note that IDs always returns len(IDs) <= 1 for unique edges, and you should use
// GroupID instead. It exists only for internal usage by the builders.
func (m *UserMutation) GroupIDs() (ids []uuid.UUID) {
if id := m.group; id != nil {
ids = append(ids, *id)
}
return
}
// ResetGroups resets all changes to the "groups" edge.
func (m *UserMutation) ResetGroups() {
m.groups = nil
m.clearedgroups = false
m.removedgroups = nil
// ResetGroup resets all changes to the "group" edge.
func (m *UserMutation) ResetGroup() {
m.group = nil
m.clearedgroup = false
}
// AddAuthTokenIDs adds the "auth_tokens" edge to the AuthTokens entity by ids.
@@ -13396,7 +13330,7 @@ func (m *UserMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UserMutation) Fields() []string {
fields := make([]string, 0, 12)
fields := make([]string, 0, 11)
if m.created_at != nil {
fields = append(fields, user.FieldCreatedAt)
}
@@ -13430,9 +13364,6 @@ func (m *UserMutation) Fields() []string {
if m.oidc_subject != nil {
fields = append(fields, user.FieldOidcSubject)
}
if m.default_group_id != nil {
fields = append(fields, user.FieldDefaultGroupID)
}
return fields
}
@@ -13463,8 +13394,6 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
return m.OidcIssuer()
case user.FieldOidcSubject:
return m.OidcSubject()
case user.FieldDefaultGroupID:
return m.DefaultGroupID()
}
return nil, false
}
@@ -13496,8 +13425,6 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
return m.OldOidcIssuer(ctx)
case user.FieldOidcSubject:
return m.OldOidcSubject(ctx)
case user.FieldDefaultGroupID:
return m.OldDefaultGroupID(ctx)
}
return nil, fmt.Errorf("unknown User field %s", name)
}
@@ -13584,13 +13511,6 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
}
m.SetOidcSubject(v)
return nil
case user.FieldDefaultGroupID:
v, ok := value.(uuid.UUID)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetDefaultGroupID(v)
return nil
}
return fmt.Errorf("unknown User field %s", name)
}
@@ -13633,9 +13553,6 @@ func (m *UserMutation) ClearedFields() []string {
if m.FieldCleared(user.FieldOidcSubject) {
fields = append(fields, user.FieldOidcSubject)
}
if m.FieldCleared(user.FieldDefaultGroupID) {
fields = append(fields, user.FieldDefaultGroupID)
}
return fields
}
@@ -13662,9 +13579,6 @@ func (m *UserMutation) ClearField(name string) error {
case user.FieldOidcSubject:
m.ClearOidcSubject()
return nil
case user.FieldDefaultGroupID:
m.ClearDefaultGroupID()
return nil
}
return fmt.Errorf("unknown User nullable field %s", name)
}
@@ -13706,9 +13620,6 @@ func (m *UserMutation) ResetField(name string) error {
case user.FieldOidcSubject:
m.ResetOidcSubject()
return nil
case user.FieldDefaultGroupID:
m.ResetDefaultGroupID()
return nil
}
return fmt.Errorf("unknown User field %s", name)
}
@@ -13716,8 +13627,8 @@ func (m *UserMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *UserMutation) AddedEdges() []string {
edges := make([]string, 0, 3)
if m.groups != nil {
edges = append(edges, user.EdgeGroups)
if m.group != nil {
edges = append(edges, user.EdgeGroup)
}
if m.auth_tokens != nil {
edges = append(edges, user.EdgeAuthTokens)
@@ -13732,12 +13643,10 @@ func (m *UserMutation) AddedEdges() []string {
// name in this mutation.
func (m *UserMutation) AddedIDs(name string) []ent.Value {
switch name {
case user.EdgeGroups:
ids := make([]ent.Value, 0, len(m.groups))
for id := range m.groups {
ids = append(ids, id)
case user.EdgeGroup:
if id := m.group; id != nil {
return []ent.Value{*id}
}
return ids
case user.EdgeAuthTokens:
ids := make([]ent.Value, 0, len(m.auth_tokens))
for id := range m.auth_tokens {
@@ -13757,9 +13666,6 @@ func (m *UserMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *UserMutation) RemovedEdges() []string {
edges := make([]string, 0, 3)
if m.removedgroups != nil {
edges = append(edges, user.EdgeGroups)
}
if m.removedauth_tokens != nil {
edges = append(edges, user.EdgeAuthTokens)
}
@@ -13773,12 +13679,6 @@ func (m *UserMutation) RemovedEdges() []string {
// the given name in this mutation.
func (m *UserMutation) RemovedIDs(name string) []ent.Value {
switch name {
case user.EdgeGroups:
ids := make([]ent.Value, 0, len(m.removedgroups))
for id := range m.removedgroups {
ids = append(ids, id)
}
return ids
case user.EdgeAuthTokens:
ids := make([]ent.Value, 0, len(m.removedauth_tokens))
for id := range m.removedauth_tokens {
@@ -13798,8 +13698,8 @@ func (m *UserMutation) RemovedIDs(name string) []ent.Value {
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *UserMutation) ClearedEdges() []string {
edges := make([]string, 0, 3)
if m.clearedgroups {
edges = append(edges, user.EdgeGroups)
if m.clearedgroup {
edges = append(edges, user.EdgeGroup)
}
if m.clearedauth_tokens {
edges = append(edges, user.EdgeAuthTokens)
@@ -13814,8 +13714,8 @@ func (m *UserMutation) ClearedEdges() []string {
// was cleared in this mutation.
func (m *UserMutation) EdgeCleared(name string) bool {
switch name {
case user.EdgeGroups:
return m.clearedgroups
case user.EdgeGroup:
return m.clearedgroup
case user.EdgeAuthTokens:
return m.clearedauth_tokens
case user.EdgeNotifiers:
@@ -13828,6 +13728,9 @@ func (m *UserMutation) EdgeCleared(name string) bool {
// if that edge is not defined in the schema.
func (m *UserMutation) ClearEdge(name string) error {
switch name {
case user.EdgeGroup:
m.ClearGroup()
return nil
}
return fmt.Errorf("unknown User unique edge %s", name)
}
@@ -13836,8 +13739,8 @@ func (m *UserMutation) ClearEdge(name string) error {
// It returns an error if the edge is not defined in the schema.
func (m *UserMutation) ResetEdge(name string) error {
switch name {
case user.EdgeGroups:
m.ResetGroups()
case user.EdgeGroup:
m.ResetGroup()
return nil
case user.EdgeAuthTokens:
m.ResetAuthTokens()

View File

@@ -42,8 +42,7 @@ func (Group) Edges() []ent.Edge {
}
return []ent.Edge{
// Use edge.From + Ref("groups") to model M:M between users and groups via junction table
edge.From("users", User.Type).Ref("groups"),
owned("users", User.Type),
owned("locations", Location.Type),
owned("items", Item.Type),
owned("labels", Label.Type),
@@ -73,14 +72,14 @@ func (g GroupMixin) Fields() []ent.Field {
}
func (g GroupMixin) Edges() []ent.Edge {
e := edge.From("group", Group.Type).
edge := edge.From("group", Group.Type).
Ref(g.ref).
Unique().
Required()
if g.field != "" {
e = e.Field(g.field)
edge = edge.Field(g.field)
}
return []ent.Edge{e}
return []ent.Edge{edge}
}

View File

@@ -19,6 +19,7 @@ type User struct {
func (User) Mixin() []ent.Mixin {
return []ent.Mixin{
mixins.BaseMixin{},
GroupMixin{ref: "users"},
}
}
@@ -53,10 +54,6 @@ func (User) Fields() []ent.Field {
field.String("oidc_subject").
Optional().
Nillable(),
// default_group_id is the user's primary tenant/group
field.UUID("default_group_id", uuid.UUID{}).
Optional().
Nillable(),
}
}
@@ -69,7 +66,6 @@ func (User) Indexes() []ent.Index {
// Edges of the User.
func (User) Edges() []ent.Edge {
return []ent.Edge{
edge.To("groups", Group.Type),
edge.To("auth_tokens", AuthTokens.Type).
Annotations(entsql.Annotation{
OnDelete: entsql.Cascade,

View File

@@ -10,6 +10,7 @@ import (
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/group"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/user"
)
@@ -40,18 +41,17 @@ type User struct {
OidcIssuer *string `json:"oidc_issuer,omitempty"`
// OidcSubject holds the value of the "oidc_subject" field.
OidcSubject *string `json:"oidc_subject,omitempty"`
// DefaultGroupID holds the value of the "default_group_id" field.
DefaultGroupID *uuid.UUID `json:"default_group_id,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the UserQuery when eager-loading is set.
Edges UserEdges `json:"edges"`
group_users *uuid.UUID
selectValues sql.SelectValues
}
// UserEdges holds the relations/edges for other nodes in the graph.
type UserEdges struct {
// Groups holds the value of the groups edge.
Groups []*Group `json:"groups,omitempty"`
// Group holds the value of the group edge.
Group *Group `json:"group,omitempty"`
// AuthTokens holds the value of the auth_tokens edge.
AuthTokens []*AuthTokens `json:"auth_tokens,omitempty"`
// Notifiers holds the value of the notifiers edge.
@@ -61,13 +61,15 @@ type UserEdges struct {
loadedTypes [3]bool
}
// GroupsOrErr returns the Groups value or an error if the edge
// was not loaded in eager-loading.
func (e UserEdges) GroupsOrErr() ([]*Group, error) {
if e.loadedTypes[0] {
return e.Groups, nil
// GroupOrErr returns the Group value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e UserEdges) GroupOrErr() (*Group, error) {
if e.Group != nil {
return e.Group, nil
} else if e.loadedTypes[0] {
return nil, &NotFoundError{label: group.Label}
}
return nil, &NotLoadedError{edge: "groups"}
return nil, &NotLoadedError{edge: "group"}
}
// AuthTokensOrErr returns the AuthTokens value or an error if the edge
@@ -93,8 +95,6 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldDefaultGroupID:
values[i] = &sql.NullScanner{S: new(uuid.UUID)}
case user.FieldIsSuperuser, user.FieldSuperuser:
values[i] = new(sql.NullBool)
case user.FieldName, user.FieldEmail, user.FieldPassword, user.FieldRole, user.FieldOidcIssuer, user.FieldOidcSubject:
@@ -103,6 +103,8 @@ func (*User) scanValues(columns []string) ([]any, error) {
values[i] = new(sql.NullTime)
case user.FieldID:
values[i] = new(uuid.UUID)
case user.ForeignKeys[0]: // group_users
values[i] = &sql.NullScanner{S: new(uuid.UUID)}
default:
values[i] = new(sql.UnknownType)
}
@@ -193,12 +195,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
_m.OidcSubject = new(string)
*_m.OidcSubject = value.String
}
case user.FieldDefaultGroupID:
case user.ForeignKeys[0]:
if value, ok := values[i].(*sql.NullScanner); !ok {
return fmt.Errorf("unexpected type %T for field default_group_id", values[i])
return fmt.Errorf("unexpected type %T for field group_users", values[i])
} else if value.Valid {
_m.DefaultGroupID = new(uuid.UUID)
*_m.DefaultGroupID = *value.S.(*uuid.UUID)
_m.group_users = new(uuid.UUID)
*_m.group_users = *value.S.(*uuid.UUID)
}
default:
_m.selectValues.Set(columns[i], values[i])
@@ -213,9 +215,9 @@ func (_m *User) Value(name string) (ent.Value, error) {
return _m.selectValues.Get(name)
}
// QueryGroups queries the "groups" edge of the User entity.
func (_m *User) QueryGroups() *GroupQuery {
return NewUserClient(_m.config).QueryGroups(_m)
// QueryGroup queries the "group" edge of the User entity.
func (_m *User) QueryGroup() *GroupQuery {
return NewUserClient(_m.config).QueryGroup(_m)
}
// QueryAuthTokens queries the "auth_tokens" edge of the User entity.
@@ -286,11 +288,6 @@ func (_m *User) String() string {
builder.WriteString("oidc_subject=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.DefaultGroupID; v != nil {
builder.WriteString("default_group_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteByte(')')
return builder.String()
}

View File

@@ -38,21 +38,21 @@ const (
FieldOidcIssuer = "oidc_issuer"
// FieldOidcSubject holds the string denoting the oidc_subject field in the database.
FieldOidcSubject = "oidc_subject"
// FieldDefaultGroupID holds the string denoting the default_group_id field in the database.
FieldDefaultGroupID = "default_group_id"
// EdgeGroups holds the string denoting the groups edge name in mutations.
EdgeGroups = "groups"
// EdgeGroup holds the string denoting the group edge name in mutations.
EdgeGroup = "group"
// EdgeAuthTokens holds the string denoting the auth_tokens edge name in mutations.
EdgeAuthTokens = "auth_tokens"
// EdgeNotifiers holds the string denoting the notifiers edge name in mutations.
EdgeNotifiers = "notifiers"
// Table holds the table name of the user in the database.
Table = "users"
// GroupsTable is the table that holds the groups relation/edge. The primary key declared below.
GroupsTable = "user_groups"
// GroupsInverseTable is the table name for the Group entity.
// GroupTable is the table that holds the group relation/edge.
GroupTable = "users"
// GroupInverseTable is the table name for the Group entity.
// It exists in this package in order to avoid circular dependency with the "group" package.
GroupsInverseTable = "groups"
GroupInverseTable = "groups"
// GroupColumn is the table column denoting the group relation/edge.
GroupColumn = "group_users"
// AuthTokensTable is the table that holds the auth_tokens relation/edge.
AuthTokensTable = "auth_tokens"
// AuthTokensInverseTable is the table name for the AuthTokens entity.
@@ -83,14 +83,13 @@ var Columns = []string{
FieldActivatedOn,
FieldOidcIssuer,
FieldOidcSubject,
FieldDefaultGroupID,
}
var (
// GroupsPrimaryKey and GroupsColumn2 are the table columns denoting the
// primary key for the groups relation (M2M).
GroupsPrimaryKey = []string{"user_id", "group_id"}
)
// ForeignKeys holds the SQL foreign-keys that are owned by the "users"
// table and are not defined as standalone fields in the schema.
var ForeignKeys = []string{
"group_users",
}
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
@@ -99,6 +98,11 @@ func ValidColumn(column string) bool {
return true
}
}
for i := range ForeignKeys {
if column == ForeignKeys[i] {
return true
}
}
return false
}
@@ -212,22 +216,10 @@ func ByOidcSubject(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldOidcSubject, opts...).ToFunc()
}
// ByDefaultGroupID orders the results by the default_group_id field.
func ByDefaultGroupID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDefaultGroupID, opts...).ToFunc()
}
// ByGroupsCount orders the results by groups count.
func ByGroupsCount(opts ...sql.OrderTermOption) OrderOption {
// ByGroupField orders the results by group field.
func ByGroupField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newGroupsStep(), opts...)
}
}
// ByGroups orders the results by groups terms.
func ByGroups(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newGroupsStep(), append([]sql.OrderTerm{term}, terms...)...)
sqlgraph.OrderByNeighborTerms(s, newGroupStep(), sql.OrderByField(field, opts...))
}
}
@@ -258,11 +250,11 @@ func ByNotifiers(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
sqlgraph.OrderByNeighborTerms(s, newNotifiersStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
func newGroupsStep() *sqlgraph.Step {
func newGroupStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(GroupsInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, GroupsTable, GroupsPrimaryKey...),
sqlgraph.To(GroupInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2O, true, GroupTable, GroupColumn),
)
}
func newAuthTokensStep() *sqlgraph.Step {

View File

@@ -106,11 +106,6 @@ func OidcSubject(v string) predicate.User {
return predicate.User(sql.FieldEQ(FieldOidcSubject, v))
}
// DefaultGroupID applies equality check predicate on the "default_group_id" field. It's identical to DefaultGroupIDEQ.
func DefaultGroupID(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldEQ(FieldDefaultGroupID, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldCreatedAt, v))
@@ -636,71 +631,21 @@ func OidcSubjectContainsFold(v string) predicate.User {
return predicate.User(sql.FieldContainsFold(FieldOidcSubject, v))
}
// DefaultGroupIDEQ applies the EQ predicate on the "default_group_id" field.
func DefaultGroupIDEQ(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldEQ(FieldDefaultGroupID, v))
}
// DefaultGroupIDNEQ applies the NEQ predicate on the "default_group_id" field.
func DefaultGroupIDNEQ(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldNEQ(FieldDefaultGroupID, v))
}
// DefaultGroupIDIn applies the In predicate on the "default_group_id" field.
func DefaultGroupIDIn(vs ...uuid.UUID) predicate.User {
return predicate.User(sql.FieldIn(FieldDefaultGroupID, vs...))
}
// DefaultGroupIDNotIn applies the NotIn predicate on the "default_group_id" field.
func DefaultGroupIDNotIn(vs ...uuid.UUID) predicate.User {
return predicate.User(sql.FieldNotIn(FieldDefaultGroupID, vs...))
}
// DefaultGroupIDGT applies the GT predicate on the "default_group_id" field.
func DefaultGroupIDGT(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldGT(FieldDefaultGroupID, v))
}
// DefaultGroupIDGTE applies the GTE predicate on the "default_group_id" field.
func DefaultGroupIDGTE(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldGTE(FieldDefaultGroupID, v))
}
// DefaultGroupIDLT applies the LT predicate on the "default_group_id" field.
func DefaultGroupIDLT(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldLT(FieldDefaultGroupID, v))
}
// DefaultGroupIDLTE applies the LTE predicate on the "default_group_id" field.
func DefaultGroupIDLTE(v uuid.UUID) predicate.User {
return predicate.User(sql.FieldLTE(FieldDefaultGroupID, v))
}
// DefaultGroupIDIsNil applies the IsNil predicate on the "default_group_id" field.
func DefaultGroupIDIsNil() predicate.User {
return predicate.User(sql.FieldIsNull(FieldDefaultGroupID))
}
// DefaultGroupIDNotNil applies the NotNil predicate on the "default_group_id" field.
func DefaultGroupIDNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldDefaultGroupID))
}
// HasGroups applies the HasEdge predicate on the "groups" edge.
func HasGroups() predicate.User {
// HasGroup applies the HasEdge predicate on the "group" edge.
func HasGroup() predicate.User {
return predicate.User(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, GroupsTable, GroupsPrimaryKey...),
sqlgraph.Edge(sqlgraph.M2O, true, GroupTable, GroupColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasGroupsWith applies the HasEdge predicate on the "groups" edge with a given conditions (other predicates).
func HasGroupsWith(preds ...predicate.Group) predicate.User {
// HasGroupWith applies the HasEdge predicate on the "group" edge with a given conditions (other predicates).
func HasGroupWith(preds ...predicate.Group) predicate.User {
return predicate.User(func(s *sql.Selector) {
step := newGroupsStep()
step := newGroupStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)

View File

@@ -162,20 +162,6 @@ func (_c *UserCreate) SetNillableOidcSubject(v *string) *UserCreate {
return _c
}
// SetDefaultGroupID sets the "default_group_id" field.
func (_c *UserCreate) SetDefaultGroupID(v uuid.UUID) *UserCreate {
_c.mutation.SetDefaultGroupID(v)
return _c
}
// SetNillableDefaultGroupID sets the "default_group_id" field if the given value is not nil.
func (_c *UserCreate) SetNillableDefaultGroupID(v *uuid.UUID) *UserCreate {
if v != nil {
_c.SetDefaultGroupID(*v)
}
return _c
}
// SetID sets the "id" field.
func (_c *UserCreate) SetID(v uuid.UUID) *UserCreate {
_c.mutation.SetID(v)
@@ -190,19 +176,15 @@ func (_c *UserCreate) SetNillableID(v *uuid.UUID) *UserCreate {
return _c
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_c *UserCreate) AddGroupIDs(ids ...uuid.UUID) *UserCreate {
_c.mutation.AddGroupIDs(ids...)
// SetGroupID sets the "group" edge to the Group entity by ID.
func (_c *UserCreate) SetGroupID(id uuid.UUID) *UserCreate {
_c.mutation.SetGroupID(id)
return _c
}
// AddGroups adds the "groups" edges to the Group entity.
func (_c *UserCreate) AddGroups(v ...*Group) *UserCreate {
ids := make([]uuid.UUID, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddGroupIDs(ids...)
// SetGroup sets the "group" edge to the Group entity.
func (_c *UserCreate) SetGroup(v *Group) *UserCreate {
return _c.SetGroupID(v.ID)
}
// AddAuthTokenIDs adds the "auth_tokens" edge to the AuthTokens entity by IDs.
@@ -339,6 +321,9 @@ func (_c *UserCreate) check() error {
return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "User.role": %w`, err)}
}
}
if len(_c.mutation.GroupIDs()) == 0 {
return &ValidationError{Name: "group", err: errors.New(`ent: missing required edge "User.group"`)}
}
return nil
}
@@ -418,16 +403,12 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldOidcSubject, field.TypeString, value)
_node.OidcSubject = &value
}
if value, ok := _c.mutation.DefaultGroupID(); ok {
_spec.SetField(user.FieldDefaultGroupID, field.TypeUUID, value)
_node.DefaultGroupID = &value
}
if nodes := _c.mutation.GroupsIDs(); len(nodes) > 0 {
if nodes := _c.mutation.GroupIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Rel: sqlgraph.M2O,
Inverse: true,
Table: user.GroupTable,
Columns: []string{user.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
@@ -436,6 +417,7 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_node.group_users = &nodes[0]
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.AuthTokensIDs(); len(nodes) > 0 {

View File

@@ -27,9 +27,10 @@ type UserQuery struct {
order []user.OrderOption
inters []Interceptor
predicates []predicate.User
withGroups *GroupQuery
withGroup *GroupQuery
withAuthTokens *AuthTokensQuery
withNotifiers *NotifierQuery
withFKs bool
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -66,8 +67,8 @@ func (_q *UserQuery) Order(o ...user.OrderOption) *UserQuery {
return _q
}
// QueryGroups chains the current query on the "groups" edge.
func (_q *UserQuery) QueryGroups() *GroupQuery {
// QueryGroup chains the current query on the "group" edge.
func (_q *UserQuery) QueryGroup() *GroupQuery {
query := (&GroupClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
@@ -80,7 +81,7 @@ func (_q *UserQuery) QueryGroups() *GroupQuery {
step := sqlgraph.NewStep(
sqlgraph.From(user.Table, user.FieldID, selector),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2M, false, user.GroupsTable, user.GroupsPrimaryKey...),
sqlgraph.Edge(sqlgraph.M2O, true, user.GroupTable, user.GroupColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
@@ -324,7 +325,7 @@ func (_q *UserQuery) Clone() *UserQuery {
order: append([]user.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.User{}, _q.predicates...),
withGroups: _q.withGroups.Clone(),
withGroup: _q.withGroup.Clone(),
withAuthTokens: _q.withAuthTokens.Clone(),
withNotifiers: _q.withNotifiers.Clone(),
// clone intermediate query.
@@ -333,14 +334,14 @@ func (_q *UserQuery) Clone() *UserQuery {
}
}
// WithGroups tells the query-builder to eager-load the nodes that are connected to
// the "groups" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *UserQuery) WithGroups(opts ...func(*GroupQuery)) *UserQuery {
// WithGroup tells the query-builder to eager-load the nodes that are connected to
// the "group" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *UserQuery) WithGroup(opts ...func(*GroupQuery)) *UserQuery {
query := (&GroupClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withGroups = query
_q.withGroup = query
return _q
}
@@ -443,13 +444,20 @@ func (_q *UserQuery) prepareQuery(ctx context.Context) error {
func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, error) {
var (
nodes = []*User{}
withFKs = _q.withFKs
_spec = _q.querySpec()
loadedTypes = [3]bool{
_q.withGroups != nil,
_q.withGroup != nil,
_q.withAuthTokens != nil,
_q.withNotifiers != nil,
}
)
if _q.withGroup != nil {
withFKs = true
}
if withFKs {
_spec.Node.Columns = append(_spec.Node.Columns, user.ForeignKeys...)
}
_spec.ScanValues = func(columns []string) ([]any, error) {
return (*User).scanValues(nil, columns)
}
@@ -468,10 +476,9 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
if len(nodes) == 0 {
return nodes, nil
}
if query := _q.withGroups; query != nil {
if err := _q.loadGroups(ctx, query, nodes,
func(n *User) { n.Edges.Groups = []*Group{} },
func(n *User, e *Group) { n.Edges.Groups = append(n.Edges.Groups, e) }); err != nil {
if query := _q.withGroup; query != nil {
if err := _q.loadGroup(ctx, query, nodes, nil,
func(n *User, e *Group) { n.Edges.Group = e }); err != nil {
return nil, err
}
}
@@ -492,63 +499,34 @@ func (_q *UserQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*User, e
return nodes, nil
}
func (_q *UserQuery) loadGroups(ctx context.Context, query *GroupQuery, nodes []*User, init func(*User), assign func(*User, *Group)) error {
edgeIDs := make([]driver.Value, len(nodes))
byID := make(map[uuid.UUID]*User)
nids := make(map[uuid.UUID]map[*User]struct{})
for i, node := range nodes {
edgeIDs[i] = node.ID
byID[node.ID] = node
if init != nil {
init(node)
func (_q *UserQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes []*User, init func(*User), assign func(*User, *Group)) error {
ids := make([]uuid.UUID, 0, len(nodes))
nodeids := make(map[uuid.UUID][]*User)
for i := range nodes {
if nodes[i].group_users == nil {
continue
}
fk := *nodes[i].group_users
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
nodeids[fk] = append(nodeids[fk], nodes[i])
}
query.Where(func(s *sql.Selector) {
joinT := sql.Table(user.GroupsTable)
s.Join(joinT).On(s.C(group.FieldID), joinT.C(user.GroupsPrimaryKey[1]))
s.Where(sql.InValues(joinT.C(user.GroupsPrimaryKey[0]), edgeIDs...))
columns := s.SelectedColumns()
s.Select(joinT.C(user.GroupsPrimaryKey[0]))
s.AppendSelect(columns...)
s.SetDistinct(false)
})
if err := query.prepareQuery(ctx); err != nil {
return err
if len(ids) == 0 {
return nil
}
qr := QuerierFunc(func(ctx context.Context, q Query) (Value, error) {
return query.sqlAll(ctx, func(_ context.Context, spec *sqlgraph.QuerySpec) {
assign := spec.Assign
values := spec.ScanValues
spec.ScanValues = func(columns []string) ([]any, error) {
values, err := values(columns[1:])
if err != nil {
return nil, err
}
return append([]any{new(uuid.UUID)}, values...), nil
}
spec.Assign = func(columns []string, values []any) error {
outValue := *values[0].(*uuid.UUID)
inValue := *values[1].(*uuid.UUID)
if nids[inValue] == nil {
nids[inValue] = map[*User]struct{}{byID[outValue]: {}}
return assign(columns[1:], values[1:])
}
nids[inValue][byID[outValue]] = struct{}{}
return nil
}
})
})
neighbors, err := withInterceptors[[]*Group](ctx, query, qr, query.inters)
query.Where(group.IDIn(ids...))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nids[n.ID]
nodes, ok := nodeids[n.ID]
if !ok {
return fmt.Errorf(`unexpected "groups" node returned %v`, n.ID)
return fmt.Errorf(`unexpected foreign-key "group_users" returned %v`, n.ID)
}
for kn := range nodes {
assign(kn, n)
for i := range nodes {
assign(nodes[i], n)
}
}
return nil

View File

@@ -188,39 +188,15 @@ func (_u *UserUpdate) ClearOidcSubject() *UserUpdate {
return _u
}
// SetDefaultGroupID sets the "default_group_id" field.
func (_u *UserUpdate) SetDefaultGroupID(v uuid.UUID) *UserUpdate {
_u.mutation.SetDefaultGroupID(v)
// SetGroupID sets the "group" edge to the Group entity by ID.
func (_u *UserUpdate) SetGroupID(id uuid.UUID) *UserUpdate {
_u.mutation.SetGroupID(id)
return _u
}
// SetNillableDefaultGroupID sets the "default_group_id" field if the given value is not nil.
func (_u *UserUpdate) SetNillableDefaultGroupID(v *uuid.UUID) *UserUpdate {
if v != nil {
_u.SetDefaultGroupID(*v)
}
return _u
}
// ClearDefaultGroupID clears the value of the "default_group_id" field.
func (_u *UserUpdate) ClearDefaultGroupID() *UserUpdate {
_u.mutation.ClearDefaultGroupID()
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *UserUpdate) AddGroupIDs(ids ...uuid.UUID) *UserUpdate {
_u.mutation.AddGroupIDs(ids...)
return _u
}
// AddGroups adds the "groups" edges to the Group entity.
func (_u *UserUpdate) AddGroups(v ...*Group) *UserUpdate {
ids := make([]uuid.UUID, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddGroupIDs(ids...)
// SetGroup sets the "group" edge to the Group entity.
func (_u *UserUpdate) SetGroup(v *Group) *UserUpdate {
return _u.SetGroupID(v.ID)
}
// AddAuthTokenIDs adds the "auth_tokens" edge to the AuthTokens entity by IDs.
@@ -258,27 +234,12 @@ func (_u *UserUpdate) Mutation() *UserMutation {
return _u.mutation
}
// ClearGroups clears all "groups" edges to the Group entity.
func (_u *UserUpdate) ClearGroups() *UserUpdate {
_u.mutation.ClearGroups()
// ClearGroup clears the "group" edge to the Group entity.
func (_u *UserUpdate) ClearGroup() *UserUpdate {
_u.mutation.ClearGroup()
return _u
}
// RemoveGroupIDs removes the "groups" edge to Group entities by IDs.
func (_u *UserUpdate) RemoveGroupIDs(ids ...uuid.UUID) *UserUpdate {
_u.mutation.RemoveGroupIDs(ids...)
return _u
}
// RemoveGroups removes "groups" edges to Group entities.
func (_u *UserUpdate) RemoveGroups(v ...*Group) *UserUpdate {
ids := make([]uuid.UUID, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveGroupIDs(ids...)
}
// ClearAuthTokens clears all "auth_tokens" edges to the AuthTokens entity.
func (_u *UserUpdate) ClearAuthTokens() *UserUpdate {
_u.mutation.ClearAuthTokens()
@@ -379,6 +340,9 @@ func (_u *UserUpdate) check() error {
return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "User.role": %w`, err)}
}
}
if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "User.group"`)
}
return nil
}
@@ -436,18 +400,12 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.OidcSubjectCleared() {
_spec.ClearField(user.FieldOidcSubject, field.TypeString)
}
if value, ok := _u.mutation.DefaultGroupID(); ok {
_spec.SetField(user.FieldDefaultGroupID, field.TypeUUID, value)
}
if _u.mutation.DefaultGroupIDCleared() {
_spec.ClearField(user.FieldDefaultGroupID, field.TypeUUID)
}
if _u.mutation.GroupsCleared() {
if _u.mutation.GroupCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Rel: sqlgraph.M2O,
Inverse: true,
Table: user.GroupTable,
Columns: []string{user.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
@@ -455,28 +413,12 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedGroupsIDs(); len(nodes) > 0 && !_u.mutation.GroupsCleared() {
if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.GroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Rel: sqlgraph.M2O,
Inverse: true,
Table: user.GroupTable,
Columns: []string{user.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
@@ -753,39 +695,15 @@ func (_u *UserUpdateOne) ClearOidcSubject() *UserUpdateOne {
return _u
}
// SetDefaultGroupID sets the "default_group_id" field.
func (_u *UserUpdateOne) SetDefaultGroupID(v uuid.UUID) *UserUpdateOne {
_u.mutation.SetDefaultGroupID(v)
// SetGroupID sets the "group" edge to the Group entity by ID.
func (_u *UserUpdateOne) SetGroupID(id uuid.UUID) *UserUpdateOne {
_u.mutation.SetGroupID(id)
return _u
}
// SetNillableDefaultGroupID sets the "default_group_id" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableDefaultGroupID(v *uuid.UUID) *UserUpdateOne {
if v != nil {
_u.SetDefaultGroupID(*v)
}
return _u
}
// ClearDefaultGroupID clears the value of the "default_group_id" field.
func (_u *UserUpdateOne) ClearDefaultGroupID() *UserUpdateOne {
_u.mutation.ClearDefaultGroupID()
return _u
}
// AddGroupIDs adds the "groups" edge to the Group entity by IDs.
func (_u *UserUpdateOne) AddGroupIDs(ids ...uuid.UUID) *UserUpdateOne {
_u.mutation.AddGroupIDs(ids...)
return _u
}
// AddGroups adds the "groups" edges to the Group entity.
func (_u *UserUpdateOne) AddGroups(v ...*Group) *UserUpdateOne {
ids := make([]uuid.UUID, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddGroupIDs(ids...)
// SetGroup sets the "group" edge to the Group entity.
func (_u *UserUpdateOne) SetGroup(v *Group) *UserUpdateOne {
return _u.SetGroupID(v.ID)
}
// AddAuthTokenIDs adds the "auth_tokens" edge to the AuthTokens entity by IDs.
@@ -823,27 +741,12 @@ func (_u *UserUpdateOne) Mutation() *UserMutation {
return _u.mutation
}
// ClearGroups clears all "groups" edges to the Group entity.
func (_u *UserUpdateOne) ClearGroups() *UserUpdateOne {
_u.mutation.ClearGroups()
// ClearGroup clears the "group" edge to the Group entity.
func (_u *UserUpdateOne) ClearGroup() *UserUpdateOne {
_u.mutation.ClearGroup()
return _u
}
// RemoveGroupIDs removes the "groups" edge to Group entities by IDs.
func (_u *UserUpdateOne) RemoveGroupIDs(ids ...uuid.UUID) *UserUpdateOne {
_u.mutation.RemoveGroupIDs(ids...)
return _u
}
// RemoveGroups removes "groups" edges to Group entities.
func (_u *UserUpdateOne) RemoveGroups(v ...*Group) *UserUpdateOne {
ids := make([]uuid.UUID, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemoveGroupIDs(ids...)
}
// ClearAuthTokens clears all "auth_tokens" edges to the AuthTokens entity.
func (_u *UserUpdateOne) ClearAuthTokens() *UserUpdateOne {
_u.mutation.ClearAuthTokens()
@@ -957,6 +860,9 @@ func (_u *UserUpdateOne) check() error {
return &ValidationError{Name: "role", err: fmt.Errorf(`ent: validator failed for field "User.role": %w`, err)}
}
}
if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "User.group"`)
}
return nil
}
@@ -1031,18 +937,12 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if _u.mutation.OidcSubjectCleared() {
_spec.ClearField(user.FieldOidcSubject, field.TypeString)
}
if value, ok := _u.mutation.DefaultGroupID(); ok {
_spec.SetField(user.FieldDefaultGroupID, field.TypeUUID, value)
}
if _u.mutation.DefaultGroupIDCleared() {
_spec.ClearField(user.FieldDefaultGroupID, field.TypeUUID)
}
if _u.mutation.GroupsCleared() {
if _u.mutation.GroupCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Rel: sqlgraph.M2O,
Inverse: true,
Table: user.GroupTable,
Columns: []string{user.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
@@ -1050,28 +950,12 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedGroupsIDs(); len(nodes) > 0 && !_u.mutation.GroupsCleared() {
if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.GroupsIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2M,
Inverse: false,
Table: user.GroupsTable,
Columns: user.GroupsPrimaryKey,
Rel: sqlgraph.M2O,
Inverse: true,
Table: user.GroupTable,
Columns: []string{user.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeUUID),

View File

@@ -31,4 +31,5 @@ func Migrations(dialect string) (embed.FS, error) {
return embed.FS{}, fmt.Errorf("unknown sql dialect: %s", dialect)
}
// This should never get hit, but just in case
return sqliteFiles, nil
}

View File

@@ -1,47 +0,0 @@
-- +goose Up
-- Create user_groups junction table for M:M relationship
CREATE TABLE IF NOT EXISTS "user_groups" (
"user_id" uuid NOT NULL,
"group_id" uuid NOT NULL,
PRIMARY KEY ("user_id", "group_id"),
CONSTRAINT "user_groups_user_id" FOREIGN KEY ("user_id") REFERENCES "users" ("id") ON UPDATE NO ACTION ON DELETE CASCADE,
CONSTRAINT "user_groups_group_id" FOREIGN KEY ("group_id") REFERENCES "groups" ("id") ON UPDATE NO ACTION ON DELETE CASCADE
);
-- Migrate existing user->group relationships to the junction table
INSERT INTO "user_groups" ("user_id", "group_id")
SELECT "id", "group_users" FROM "users" WHERE "group_users" IS NOT NULL;
-- Add default_group_id column to users table
ALTER TABLE "users" ADD COLUMN "default_group_id" uuid;
-- Set default_group_id to the user's current group
UPDATE "users" SET "default_group_id" = "group_users" WHERE "group_users" IS NOT NULL;
-- Drop the old group_users foreign key constraint and column
ALTER TABLE "users" DROP CONSTRAINT "users_groups_users";
ALTER TABLE "users" DROP COLUMN "group_users";
-- Add foreign key constraint for default_group_id
ALTER TABLE "users" ADD CONSTRAINT "users_groups_users_default" FOREIGN KEY ("default_group_id") REFERENCES "groups" ("id") ON UPDATE NO ACTION ON DELETE SET NULL;
-- +goose Down
-- Recreate group_users column with foreign key
ALTER TABLE "users" ADD COLUMN "group_users" uuid;
-- Restore the group_users values from user_groups (using the default_group_id or first entry)
UPDATE "users"
SET "group_users" = COALESCE("default_group_id", (
SELECT "group_id" FROM "user_groups" WHERE "user_id" = "users"."id" LIMIT 1
));
-- Drop the default_group_id foreign key and column
ALTER TABLE "users" DROP CONSTRAINT "users_groups_users_default";
ALTER TABLE "users" DROP COLUMN "default_group_id";
-- Add back the original foreign key constraint
ALTER TABLE "users" ADD CONSTRAINT "users_groups_users" FOREIGN KEY ("group_users") REFERENCES "groups" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;
-- Drop the junction table
DROP TABLE IF EXISTS "user_groups";

View File

@@ -1,68 +0,0 @@
-- +goose Up
-- +goose no transaction
-- Turn off foreign key constraints because otherwise we'll wipe notifiers out of the database when dropping the older users table
PRAGMA foreign_keys=OFF;
-- Create user_groups junction table for M:M relationship
CREATE TABLE IF NOT EXISTS user_groups (
user_id UUID NOT NULL,
group_id UUID NOT NULL,
PRIMARY KEY (user_id, group_id),
CONSTRAINT user_groups_user_id FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT user_groups_group_id FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
);
-- Migrate existing user->group relationships to the junction table
INSERT INTO user_groups (user_id, group_id)
SELECT id, group_users FROM users WHERE group_users IS NOT NULL;
-- Add default_group_id column to users table
ALTER TABLE users ADD COLUMN default_group_id UUID;
-- Set default_group_id to the user's current group
UPDATE users SET default_group_id = group_users WHERE group_users IS NOT NULL;
-- Add foreign key constraint for default_group_id
CREATE TABLE users_new (
id UUID NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
name TEXT NOT NULL,
email TEXT NOT NULL UNIQUE,
password TEXT,
is_superuser BOOLEAN NOT NULL DEFAULT false,
superuser BOOLEAN NOT NULL DEFAULT false,
role TEXT NOT NULL DEFAULT 'user',
activated_on DATETIME,
oidc_issuer TEXT,
oidc_subject TEXT,
default_group_id UUID,
PRIMARY KEY (id),
CONSTRAINT users_groups_users_default FOREIGN KEY (default_group_id) REFERENCES groups(id) ON DELETE SET NULL,
UNIQUE (oidc_issuer, oidc_subject)
);
-- Copy data from old table to new table
INSERT INTO users_new (
id, created_at, updated_at, name, email, password, is_superuser, superuser, role,
activated_on, oidc_issuer, oidc_subject, default_group_id
)
SELECT
id, created_at, updated_at, name, email, password, is_superuser, superuser, role,
activated_on, oidc_issuer, oidc_subject, default_group_id
FROM users;
-- Drop old indexes
DROP INDEX IF EXISTS users_email_key;
DROP INDEX IF EXISTS users_oidc_issuer_subject_key;
-- Drop old table
DROP TABLE users;
-- Rename new table to users
ALTER TABLE users_new RENAME TO users;
-- Recreate indexes
CREATE UNIQUE INDEX IF NOT EXISTS users_email_key ON users(email);
CREATE UNIQUE INDEX IF NOT EXISTS users_oidc_issuer_subject_key ON users(oidc_issuer, oidc_subject);
PRAGMA foreign_keys=ON;

View File

@@ -2,7 +2,6 @@ package repo
import (
"context"
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
"log"
"os"
@@ -30,7 +29,7 @@ func bootstrap() {
ctx = context.Background()
)
tGroup, err = tRepos.Groups.GroupCreate(ctx, "test-group", uuid.Nil)
tGroup, err = tRepos.Groups.GroupCreate(ctx, "test-group")
if err != nil {
log.Fatal(err)
}

View File

@@ -223,7 +223,7 @@ func (r *GroupRepository) StatsPurchasePrice(ctx context.Context, gid uuid.UUID,
func (r *GroupRepository) StatsGroup(ctx context.Context, gid uuid.UUID) (GroupStatistics, error) {
q := `
SELECT
(SELECT COUNT(*) FROM user_groups WHERE group_id = $2) AS total_users,
(SELECT COUNT(*) FROM users WHERE group_users = $2) AS total_users,
(SELECT COUNT(*) FROM items WHERE group_items = $2 AND items.archived = false) AS total_items,
(SELECT COUNT(*) FROM locations WHERE group_locations = $2) AS total_locations,
(SELECT COUNT(*) FROM labels WHERE group_labels = $2) AS total_labels,
@@ -252,15 +252,10 @@ func (r *GroupRepository) StatsGroup(ctx context.Context, gid uuid.UUID) (GroupS
return stats, nil
}
func (r *GroupRepository) GroupCreate(ctx context.Context, name string, userID uuid.UUID) (Group, error) {
createQuery := r.db.Group.Create().SetName(name)
// Only link user if a valid user ID is provided
if userID != uuid.Nil {
createQuery = createQuery.AddUserIDs(userID)
}
return r.groupMapper.MapErr(createQuery.Save(ctx))
func (r *GroupRepository) GroupCreate(ctx context.Context, name string) (Group, error) {
return r.groupMapper.MapErr(r.db.Group.Create().
SetName(name).
Save(ctx))
}
func (r *GroupRepository) GroupUpdate(ctx context.Context, id uuid.UUID, data GroupUpdate) (Group, error) {
@@ -276,10 +271,6 @@ func (r *GroupRepository) GroupByID(ctx context.Context, id uuid.UUID) (Group, e
return r.groupMapper.MapErr(r.db.Group.Get(ctx, id))
}
func (r *GroupRepository) GroupDelete(ctx context.Context, id uuid.UUID) error {
return r.db.Group.DeleteOneID(id).Exec(ctx)
}
func (r *GroupRepository) InvitationGet(ctx context.Context, token []byte) (GroupInvitation, error) {
return r.invitationMapper.MapErr(r.db.GroupInvitationToken.Query().
Where(groupinvitationtoken.Token(token)).
@@ -287,18 +278,6 @@ func (r *GroupRepository) InvitationGet(ctx context.Context, token []byte) (Grou
Only(ctx))
}
func (r *GroupRepository) InvitationGetAll(ctx context.Context, groupID uuid.UUID) ([]GroupInvitation, error) {
invitations, err := r.db.GroupInvitationToken.Query().
Where(groupinvitationtoken.HasGroupWith(group.ID(groupID))).
WithGroup().
All(ctx)
if err != nil {
return nil, err
}
return r.invitationMapper.MapEach(invitations), nil
}
func (r *GroupRepository) InvitationCreate(ctx context.Context, groupID uuid.UUID, invite GroupInvitationCreate) (GroupInvitation, error) {
entity, err := r.db.GroupInvitationToken.Create().
SetGroupID(groupID).
@@ -329,11 +308,3 @@ func (r *GroupRepository) InvitationPurge(ctx context.Context) (amount int, err
return q.Exec(ctx)
}
func (r *GroupRepository) AddMember(ctx context.Context, groupID, userID uuid.UUID) error {
return r.db.Group.UpdateOneID(groupID).AddUserIDs(userID).Exec(ctx)
}
func (r *GroupRepository) RemoveMember(ctx context.Context, groupID, userID uuid.UUID) error {
return r.db.Group.UpdateOneID(groupID).RemoveUserIDs(userID).Exec(ctx)
}

View File

@@ -4,13 +4,12 @@ import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_Group_Create(t *testing.T) {
g, err := tRepos.Groups.GroupCreate(context.Background(), "test", uuid.Nil)
g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
require.NoError(t, err)
assert.Equal(t, "test", g.Name)
@@ -22,7 +21,7 @@ func Test_Group_Create(t *testing.T) {
}
func Test_Group_Update(t *testing.T) {
g, err := tRepos.Groups.GroupCreate(context.Background(), "test", uuid.Nil)
g, err := tRepos.Groups.GroupCreate(context.Background(), "test")
require.NoError(t, err)
g, err = tRepos.Groups.GroupUpdate(context.Background(), g.ID, GroupUpdate{

View File

@@ -114,17 +114,17 @@ func (r *AttachmentRepo) fullPath(relativePath string) string {
// Normalize path separators to forward slashes for blob storage
// The blob library expects forward slashes in keys regardless of OS
normalizedRelativePath := normalizePath(relativePath)
// Always use forward slashes when joining paths for blob storage
if r.storage.PrefixPath == "" {
return normalizedRelativePath
}
normalizedPrefix := normalizePath(r.storage.PrefixPath)
if normalizedPrefix == "" {
return normalizedRelativePath
}
return fmt.Sprintf("%s/%s", normalizedPrefix, normalizedRelativePath)
}

View File

@@ -290,25 +290,25 @@ func TestAttachmentRepo_PathNormalization(t *testing.T) {
PrefixPath: ".data",
},
}
testGUID := uuid.MustParse("eb6bf410-a1a8-478d-a803-ca3948368a0c")
testHash := "f295eb01-18a9-4631-a797-70bd9623edd4.png"
// Test path() method - should always return forward slashes
relativePath := repo.path(testGUID, testHash)
assert.Equal(t, "eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", relativePath)
assert.NotContains(t, relativePath, "\\", "path() should not contain backslashes")
// Test fullPath() with forward slash input (from database)
fullPath := repo.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
assert.Equal(t, ".data/eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPath)
assert.NotContains(t, fullPath, "\\", "fullPath() should not contain backslashes")
// Test fullPath() with backslash input (legacy Windows paths from old database)
fullPathWithBackslash := repo.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c\\documents\\f295eb01-18a9-4631-a797-70bd9623edd4.png")
assert.Equal(t, ".data/eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPathWithBackslash)
assert.NotContains(t, fullPathWithBackslash, "\\", "fullPath() should normalize backslashes to forward slashes")
// Test with Windows-style prefix path
repoWindows := &AttachmentRepo{
storage: config.Storage{
@@ -317,7 +317,7 @@ func TestAttachmentRepo_PathNormalization(t *testing.T) {
}
fullPathWindows := repoWindows.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
assert.NotContains(t, fullPathWindows, "\\", "fullPath() should normalize Windows paths")
// Test empty prefix
repoNoPrefix := &AttachmentRepo{
storage: config.Storage{
@@ -326,7 +326,7 @@ func TestAttachmentRepo_PathNormalization(t *testing.T) {
}
fullPathNoPrefix := repoNoPrefix.fullPath("eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png")
assert.Equal(t, "eb6bf410-a1a8-478d-a803-ca3948368a0c/documents/f295eb01-18a9-4631-a797-70bd9623edd4.png", fullPathNoPrefix)
// Test with single slash prefix (like in tests)
repoSlashPrefix := &AttachmentRepo{
storage: config.Storage{

View File

@@ -43,18 +43,18 @@ type (
Notes string `json:"notes" validate:"max=1000"`
// Default values for items
DefaultQuantity *int `json:"defaultQuantity,omitempty" extensions:"x-nullable"`
DefaultQuantity *int `json:"defaultQuantity,omitempty" extensions:"x-nullable"`
DefaultInsured bool `json:"defaultInsured"`
DefaultName *string `json:"defaultName,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultDescription *string `json:"defaultDescription,omitempty" validate:"omitempty,max=1000" extensions:"x-nullable"`
DefaultManufacturer *string `json:"defaultManufacturer,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultModelNumber *string `json:"defaultModelNumber,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultName *string `json:"defaultName,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultDescription *string `json:"defaultDescription,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
DefaultManufacturer *string `json:"defaultManufacturer,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultModelNumber *string `json:"defaultModelNumber,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultLifetimeWarranty bool `json:"defaultLifetimeWarranty"`
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" validate:"omitempty,max=1000" extensions:"x-nullable"`
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
// Default location and labels
DefaultLocationID uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
// Metadata flags
IncludeWarrantyFields bool `json:"includeWarrantyFields"`
@@ -72,18 +72,18 @@ type (
Notes string `json:"notes" validate:"max=1000"`
// Default values for items
DefaultQuantity *int `json:"defaultQuantity,omitempty" extensions:"x-nullable"`
DefaultQuantity *int `json:"defaultQuantity,omitempty" extensions:"x-nullable"`
DefaultInsured bool `json:"defaultInsured"`
DefaultName *string `json:"defaultName,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultDescription *string `json:"defaultDescription,omitempty" validate:"omitempty,max=1000" extensions:"x-nullable"`
DefaultManufacturer *string `json:"defaultManufacturer,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultModelNumber *string `json:"defaultModelNumber,omitempty" validate:"omitempty,max=255" extensions:"x-nullable"`
DefaultName *string `json:"defaultName,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultDescription *string `json:"defaultDescription,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
DefaultManufacturer *string `json:"defaultManufacturer,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultModelNumber *string `json:"defaultModelNumber,omitempty" extensions:"x-nullable" validate:"omitempty,max=255"`
DefaultLifetimeWarranty bool `json:"defaultLifetimeWarranty"`
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" validate:"omitempty,max=1000" extensions:"x-nullable"`
DefaultWarrantyDetails *string `json:"defaultWarrantyDetails,omitempty" extensions:"x-nullable" validate:"omitempty,max=1000"`
// Default location and labels
DefaultLocationID uuid.UUID `json:"defaultLocationId,omitempty" extensions:"x-nullable"`
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
DefaultLabelIDs *[]uuid.UUID `json:"defaultLabelIds,omitempty" extensions:"x-nullable"`
// Metadata flags
IncludeWarrantyFields bool `json:"includeWarrantyFields"`

View File

@@ -164,7 +164,7 @@ func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
if tc.shouldMatch {
// If it should match, then either the original query should match
// or the normalized query should match when applied to the stored data
assert.NotEmpty(t, normalizedSearch, "Normalized search should not be empty")
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
// The key insight is that we're searching with both the original and normalized queries
// So "electrónica" will be found when searching for "electronica" because:

View File

@@ -313,7 +313,7 @@ func TestItemRepository_GetAllCustomFields(t *testing.T) {
// Test getting all values from field
{
results, err := tRepos.Items.GetAllCustomFieldValues(context.Background(), tUser.DefaultGroupID, names[0])
results, err := tRepos.Items.GetAllCustomFieldValues(context.Background(), tUser.GroupID, names[0])
require.NoError(t, err)
assert.ElementsMatch(t, values[:1], results)
@@ -400,33 +400,33 @@ func TestItemsRepository_DeleteByGroupWithAttachments(t *testing.T) {
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",
@@ -435,7 +435,7 @@ func TestItemsRepository_WipeInventory(t *testing.T) {
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",
@@ -443,7 +443,7 @@ func TestItemsRepository_WipeInventory(t *testing.T) {
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()),
@@ -452,7 +452,7 @@ func TestItemsRepository_WipeInventory(t *testing.T) {
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",
@@ -460,40 +460,40 @@ func TestItemsRepository_WipeInventory(t *testing.T) {
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.Positive(t, deleted, "Should have deleted at least some entities")
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")
})
@@ -506,13 +506,13 @@ func TestItemsRepository_WipeInventory_OnlyItems(t *testing.T) {
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",
@@ -520,7 +520,7 @@ func TestItemsRepository_WipeInventory_OnlyItems(t *testing.T) {
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",
@@ -528,30 +528,31 @@ func TestItemsRepository_WipeInventory_OnlyItems(t *testing.T) {
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.Positive(t, deleted, "Should have deleted at least the item")
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)
}

View File

@@ -40,7 +40,7 @@ func (r *TokenRepository) GetUserFromToken(ctx context.Context, token []byte) (U
Where(authtokens.ExpiresAtGTE(time.Now())).
WithUser().
QueryUser().
WithGroups().
WithGroup().
Only(ctx)
if err != nil {
return UserOut{}, err

View File

@@ -5,7 +5,6 @@ import (
"github.com/google/uuid"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/group"
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/user"
)
@@ -18,12 +17,12 @@ type (
// in the database. It should to create users from an API unless the user has
// rights to create SuperUsers. For regular user in data use the UserIn struct.
UserCreate struct {
Name string `json:"name"`
Email string `json:"email"`
Password *string `json:"password"`
IsSuperuser bool `json:"isSuperUser"`
DefaultGroupID uuid.UUID `json:"defaultGroupID"`
IsOwner bool `json:"isOwner"`
Name string `json:"name"`
Email string `json:"email"`
Password *string `json:"password"`
IsSuperuser bool `json:"isSuperuser"`
GroupID uuid.UUID `json:"groupID"`
IsOwner bool `json:"isOwner"`
}
UserUpdate struct {
@@ -32,16 +31,16 @@ type (
}
UserOut struct {
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsSuperuser bool `json:"isSuperuser"`
DefaultGroupID uuid.UUID `json:"defaultGroupId"`
GroupIDs []uuid.UUID `json:"groupIds"`
PasswordHash string `json:"-"`
IsOwner bool `json:"isOwner"`
OidcIssuer *string `json:"oidcIssuer"`
OidcSubject *string `json:"oidcSubject"`
ID uuid.UUID `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
IsSuperuser bool `json:"isSuperuser"`
GroupID uuid.UUID `json:"groupId"`
GroupName string `json:"groupName"`
PasswordHash string `json:"-"`
IsOwner bool `json:"isOwner"`
OidcIssuer *string `json:"oidcIssuer"`
OidcSubject *string `json:"oidcSubject"`
}
)
@@ -56,55 +55,37 @@ func mapUserOut(user *ent.User) UserOut {
passwordHash = *user.Password
}
groupIDs := make([]uuid.UUID, len(user.Edges.Groups))
for i, g := range user.Edges.Groups {
groupIDs[i] = g.ID
}
// Get the default group ID, handling the optional pointer
defaultGroupID := uuid.Nil
if user.DefaultGroupID != nil {
defaultGroupID = *user.DefaultGroupID
}
return UserOut{
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsSuperuser: user.IsSuperuser,
DefaultGroupID: defaultGroupID,
GroupIDs: groupIDs,
PasswordHash: passwordHash,
IsOwner: user.Role == "owner",
OidcIssuer: user.OidcIssuer,
OidcSubject: user.OidcSubject,
ID: user.ID,
Name: user.Name,
Email: user.Email,
IsSuperuser: user.IsSuperuser,
GroupID: user.Edges.Group.ID,
GroupName: user.Edges.Group.Name,
PasswordHash: passwordHash,
IsOwner: user.Role == "owner",
OidcIssuer: user.OidcIssuer,
OidcSubject: user.OidcSubject,
}
}
func (r *UserRepository) GetOneID(ctx context.Context, id uuid.UUID) (UserOut, error) {
return mapUserOutErr(r.db.User.Query().
Where(user.ID(id)).
WithGroups().
WithGroup().
Only(ctx))
}
func (r *UserRepository) GetOneEmail(ctx context.Context, email string) (UserOut, error) {
return mapUserOutErr(r.db.User.Query().
Where(user.EmailEqualFold(email)).
WithGroups().
Only(ctx),
)
}
func (r *UserRepository) GetOneEmailNoEdges(ctx context.Context, email string) (UserOut, error) {
return mapUserOutErr(r.db.User.Query().
Where(user.EmailEqualFold(email)).
WithGroup().
Only(ctx),
)
}
func (r *UserRepository) GetAll(ctx context.Context) ([]UserOut, error) {
return mapUsersOutErr(r.db.User.Query().WithGroups().All(ctx))
return mapUsersOutErr(r.db.User.Query().WithGroup().All(ctx))
}
func (r *UserRepository) Create(ctx context.Context, usr UserCreate) (UserOut, error) {
@@ -118,9 +99,8 @@ func (r *UserRepository) Create(ctx context.Context, usr UserCreate) (UserOut, e
SetName(usr.Name).
SetEmail(usr.Email).
SetIsSuperuser(usr.IsSuperuser).
SetDefaultGroupID(usr.DefaultGroupID).
SetRole(role).
AddGroupIDs(usr.DefaultGroupID)
SetGroupID(usr.GroupID).
SetRole(role)
// Only set password if provided (non-nil)
if usr.Password != nil {
@@ -146,11 +126,10 @@ func (r *UserRepository) CreateWithOIDC(ctx context.Context, usr UserCreate, iss
SetName(usr.Name).
SetEmail(usr.Email).
SetIsSuperuser(usr.IsSuperuser).
SetDefaultGroupID(usr.DefaultGroupID).
SetGroupID(usr.GroupID).
SetRole(role).
SetOidcIssuer(issuer).
SetOidcSubject(subject).
AddGroupIDs(usr.DefaultGroupID)
SetOidcSubject(subject)
if usr.Password != nil {
createQuery = createQuery.SetPassword(*usr.Password)
@@ -204,13 +183,6 @@ func (r *UserRepository) SetOIDCIdentity(ctx context.Context, uid uuid.UUID, iss
func (r *UserRepository) GetOneOIDC(ctx context.Context, issuer, subject string) (UserOut, error) {
return mapUserOutErr(r.db.User.Query().
Where(user.OidcIssuerEQ(issuer), user.OidcSubjectEQ(subject)).
WithGroups().
WithGroup().
Only(ctx))
}
func (r *UserRepository) GetUsersByGroupID(ctx context.Context, gid uuid.UUID) ([]UserOut, error) {
return mapUsersOutErr(r.db.User.Query().
WithGroups().
Where(user.HasGroupsWith(group.ID(gid))).
All(ctx))
}

View File

@@ -4,7 +4,6 @@ import (
"context"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -12,11 +11,11 @@ import (
func userFactory() UserCreate {
password := fk.Str(10)
return UserCreate{
Name: fk.Str(10),
Email: fk.Email(),
Password: &password,
IsSuperuser: fk.Bool(),
DefaultGroupID: tGroup.ID,
Name: fk.Str(10),
Email: fk.Email(),
Password: &password,
IsSuperuser: fk.Bool(),
GroupID: tGroup.ID,
}
}
@@ -88,8 +87,7 @@ func TestUserRepo_GetAll(t *testing.T) {
assert.Equal(t, usr.Email, usr2.Email)
// Check groups are loaded
assert.NotEqual(t, uuid.Nil, usr2.DefaultGroupID)
assert.NotEmpty(t, usr2.GroupIDs)
assert.NotNil(t, usr2.GroupID)
}
}
}

View File

@@ -21,26 +21,26 @@ func TestWipeInventory_Integration(t *testing.T) {
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",
@@ -49,7 +49,7 @@ func TestWipeInventory_Integration(t *testing.T) {
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",
@@ -57,7 +57,7 @@ func TestWipeInventory_Integration(t *testing.T) {
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",
@@ -65,7 +65,7 @@ func TestWipeInventory_Integration(t *testing.T) {
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()),
@@ -74,7 +74,7 @@ func TestWipeInventory_Integration(t *testing.T) {
Cost: 0,
})
require.NoError(t, err)
_, err = tRepos.MaintEntry.Create(context.Background(), item2.ID, MaintenanceEntryCreate{
CompletedDate: types.DateFromTime(time.Now()),
Name: "Drill maintenance",
@@ -82,7 +82,7 @@ func TestWipeInventory_Integration(t *testing.T) {
Cost: 5.00,
})
require.NoError(t, err)
_, err = tRepos.MaintEntry.Create(context.Background(), item3.ID, MaintenanceEntryCreate{
CompletedDate: types.DateFromTime(time.Now()),
Name: "Monitor calibration",
@@ -90,47 +90,47 @@ func TestWipeInventory_Integration(t *testing.T) {
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.Positive(t, deleted, "Should have deleted entities")
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.Empty(t, allItemsAfter, "All items should be deleted")
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")
}
@@ -143,13 +143,13 @@ func TestWipeInventory_SelectiveWipe(t *testing.T) {
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",
@@ -157,7 +157,7 @@ func TestWipeInventory_SelectiveWipe(t *testing.T) {
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",
@@ -165,29 +165,29 @@ func TestWipeInventory_SelectiveWipe(t *testing.T) {
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.Positive(t, deleted, "Should have deleted at least items")
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)

View File

@@ -65,14 +65,14 @@ type WebConfig struct {
}
type LabelMakerConf struct {
Width int64 `yaml:"width" conf:"default:526"`
Height int64 `yaml:"height" conf:"default:200"`
Padding int64 `yaml:"padding" conf:"default:32"`
Margin int64 `yaml:"margin" conf:"default:32"`
FontSize float64 `yaml:"font_size" conf:"default:32.0"`
Width int64 `yaml:"width" conf:"default:526"`
Height int64 `yaml:"height" conf:"default:200"`
Padding int64 `yaml:"padding" conf:"default:32"`
Margin int64 `yaml:"margin" conf:"default:32"`
FontSize float64 `yaml:"font_size" conf:"default:32.0"`
PrintCommand *string `yaml:"string"`
AdditionalInformation *string `yaml:"string"`
DynamicLength bool `yaml:"bool" conf:"default:true"`
DynamicLength bool `yaml:"bool" conf:"default:true"`
LabelServiceUrl *string `yaml:"label_service_url"`
LabelServiceTimeout *time.Duration `yaml:"label_service_timeout"`
RegularFontPath *string `yaml:"regular_font_path"`
@@ -80,13 +80,13 @@ type LabelMakerConf struct {
}
type OIDCConf struct {
Enabled bool `yaml:"enabled" conf:"default:false"`
Enabled bool `yaml:"enabled" conf:"default:false"`
IssuerURL string `yaml:"issuer_url"`
ClientID string `yaml:"client_id"`
ClientSecret string `yaml:"client_secret"`
Scope string `yaml:"scope" conf:"default:openid profile email"`
AllowedGroups string `yaml:"allowed_groups"`
AutoRedirect bool `yaml:"auto_redirect" conf:"default:false"`
AutoRedirect bool `yaml:"auto_redirect" conf:"default:false"`
VerifyEmail bool `yaml:"verify_email" conf:"default:false"`
GroupClaim string `yaml:"group_claim" conf:"default:groups"`
EmailClaim string `yaml:"email_claim" conf:"default:email"`

View File

@@ -22,13 +22,13 @@ func RemoveAccents(text string) string {
// 2. Removes diacritical marks (combining characters)
// 3. Normalizes back to NFC (canonical composition)
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
result, _, err := transform.String(t, text)
if err != nil {
// If transformation fails, return the original text
return text
}
return result
}

View File

@@ -241,15 +241,12 @@
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.Group"
}
"$ref": "#/definitions/repo.Group"
}
}
}
@@ -289,31 +286,6 @@
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.GroupInvitation"
}
}
}
}
},
"post": {
"security": [
{
@@ -464,147 +436,6 @@
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Create Group",
"parameters": [
{
"description": "Group Name",
"name": "name",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.UserOut"
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"parameters": [
{
"description": "User ID",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.GroupMemberAdd"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3696,10 +3527,6 @@
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3760,12 +3587,13 @@
"$ref": "#/definitions/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/definitions/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/definitions/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -3859,23 +3687,6 @@
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/definitions/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5093,17 +4904,14 @@
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5324,17 +5132,6 @@
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -719,9 +719,6 @@ definitions:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
default_group_id:
description: DefaultGroupID holds the value of the "default_group_id" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.UserEdges'
@@ -764,11 +761,10 @@ definitions:
items:
$ref: '#/definitions/ent.AuthTokens'
type: array
groups:
description: Groups holds the value of the groups edge.
items:
$ref: '#/definitions/ent.Group'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
notifiers:
description: Notifiers holds the value of the notifiers edge.
items:
@@ -832,17 +828,6 @@ definitions:
updatedAt:
type: string
type: object
repo.GroupInvitation:
properties:
expiresAt:
type: string
group:
$ref: '#/definitions/repo.Group'
id:
type: string
uses:
type: integer
type: object
repo.GroupStatistics:
properties:
totalItemPrice:
@@ -1675,14 +1660,12 @@ definitions:
type: object
repo.UserOut:
properties:
defaultGroupId:
type: string
email:
type: string
groupIds:
items:
type: string
type: array
groupId:
type: string
groupName:
type: string
id:
type: string
isOwner:
@@ -1827,13 +1810,6 @@ definitions:
required:
- uses
type: object
v1.GroupMemberAdd:
properties:
userId:
type: string
required:
- userId
type: object
v1.ItemAttachmentToken:
properties:
token:
@@ -2056,12 +2032,10 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.Group'
type: array
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get All Groups
summary: Get Group
tags:
- Group
put:
@@ -2084,106 +2058,7 @@ paths:
summary: Update Group
tags:
- Group
/v1/groups/{id}:
delete:
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Delete Group
tags:
- Group
post:
parameters:
- description: Group Name
in: body
name: name
required: true
schema:
type: string
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Create Group
tags:
- Group
/v1/groups/{id}/members:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.UserOut'
type: array
security:
- Bearer: []
summary: Get All Group Members
tags:
- Group
post:
parameters:
- description: User ID
in: body
name: payload
required: true
schema:
$ref: '#/definitions/v1.GroupMemberAdd'
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Add User to Group
tags:
- Group
/v1/groups/{id}/members/{user_id}:
delete:
parameters:
- description: User ID
in: path
name: user_id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Remove User from Group
tags:
- Group
/v1/groups/invitations:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.GroupInvitation'
type: array
security:
- Bearer: []
summary: Get All Group Invitations
tags:
- Group
post:
parameters:
- description: User Data

View File

@@ -242,17 +242,14 @@
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.Group"
}
"$ref": "#/components/schemas/repo.Group"
}
}
}
@@ -295,32 +292,6 @@
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.GroupInvitation"
}
}
}
}
}
}
},
"post": {
"security": [
{
@@ -480,142 +451,6 @@
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Create Group",
"requestBody": {
"content": {
"application/json": {
"schema": {
"type": "string"
}
}
},
"description": "Group Name",
"required": true
},
"responses": {
"201": {
"description": "Created",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/repo.Group"
}
}
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/repo.UserOut"
}
}
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/v1.GroupMemberAdd"
}
}
},
"description": "User ID",
"required": true
},
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3892,10 +3727,6 @@
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3956,12 +3787,13 @@
"$ref": "#/components/schemas/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/components/schemas/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/components/schemas/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -4055,23 +3887,6 @@
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/components/schemas/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5289,17 +5104,14 @@
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5520,17 +5332,6 @@
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -142,16 +142,14 @@ paths:
- Bearer: []
tags:
- Group
summary: Get All Groups
summary: Get Group
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.Group"
$ref: "#/components/schemas/repo.Group"
put:
security:
- Bearer: []
@@ -173,21 +171,6 @@ paths:
schema:
$ref: "#/components/schemas/repo.Group"
/v1/groups/invitations:
get:
security:
- Bearer: []
tags:
- Group
summary: Get All Group Invitations
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.GroupInvitation"
post:
security:
- Bearer: []
@@ -279,85 +262,6 @@ paths:
application/json:
schema:
$ref: "#/components/schemas/repo.ValueOverTime"
"/v1/groups/{id}":
post:
security:
- Bearer: []
tags:
- Group
summary: Create Group
requestBody:
content:
application/json:
schema:
type: string
description: Group Name
required: true
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/repo.Group"
delete:
security:
- Bearer: []
tags:
- Group
summary: Delete Group
responses:
"204":
description: No Content
"/v1/groups/{id}/members":
get:
security:
- Bearer: []
tags:
- Group
summary: Get All Group Members
responses:
"200":
description: OK
content:
application/json:
schema:
type: array
items:
$ref: "#/components/schemas/repo.UserOut"
post:
security:
- Bearer: []
tags:
- Group
summary: Add User to Group
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/v1.GroupMemberAdd"
description: User ID
required: true
responses:
"204":
description: No Content
"/v1/groups/{id}/members/{user_id}":
delete:
security:
- Bearer: []
tags:
- Group
summary: Remove User from Group
parameters:
- description: User ID
name: user_id
in: path
required: true
schema:
type: string
responses:
"204":
description: No Content
/v1/items:
get:
security:
@@ -2418,9 +2322,6 @@ components:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
default_group_id:
description: DefaultGroupID holds the value of the "default_group_id" field.
type: string
edges:
description: >-
Edges holds the relations/edges for other nodes in the graph.
@@ -2464,11 +2365,10 @@ components:
type: array
items:
$ref: "#/components/schemas/ent.AuthTokens"
groups:
description: Groups holds the value of the groups edge.
type: array
items:
$ref: "#/components/schemas/ent.Group"
group:
description: Group holds the value of the group edge.
allOf:
- $ref: "#/components/schemas/ent.Group"
notifiers:
description: Notifiers holds the value of the notifiers edge.
type: array
@@ -2531,17 +2431,6 @@ components:
type: string
updatedAt:
type: string
repo.GroupInvitation:
type: object
properties:
expiresAt:
type: string
group:
$ref: "#/components/schemas/repo.Group"
id:
type: string
uses:
type: integer
repo.GroupStatistics:
type: object
properties:
@@ -3375,14 +3264,12 @@ components:
repo.UserOut:
type: object
properties:
defaultGroupId:
type: string
email:
type: string
groupIds:
type: array
items:
type: string
groupId:
type: string
groupName:
type: string
id:
type: string
isOwner:
@@ -3526,13 +3413,6 @@ components:
type: integer
maximum: 100
minimum: 1
v1.GroupMemberAdd:
type: object
required:
- userId
properties:
userId:
type: string
v1.ItemAttachmentToken:
type: object
properties:

View File

@@ -241,15 +241,12 @@
"tags": [
"Group"
],
"summary": "Get All Groups",
"summary": "Get Group",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.Group"
}
"$ref": "#/definitions/repo.Group"
}
}
}
@@ -289,31 +286,6 @@
}
},
"/v1/groups/invitations": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Invitations",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.GroupInvitation"
}
}
}
}
},
"post": {
"security": [
{
@@ -464,147 +436,6 @@
}
}
},
"/v1/groups/{id}": {
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Create Group",
"parameters": [
{
"description": "Group Name",
"name": "name",
"in": "body",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/repo.Group"
}
}
}
},
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Delete Group",
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members": {
"get": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Get All Group Members",
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/repo.UserOut"
}
}
}
}
},
"post": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Add User to Group",
"parameters": [
{
"description": "User ID",
"name": "payload",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/v1.GroupMemberAdd"
}
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/groups/{id}/members/{user_id}": {
"delete": {
"security": [
{
"Bearer": []
}
],
"produces": [
"application/json"
],
"tags": [
"Group"
],
"summary": "Remove User from Group",
"parameters": [
{
"type": "string",
"description": "User ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/v1/items": {
"get": {
"security": [
@@ -3696,10 +3527,6 @@
"description": "CreatedAt holds the value of the \"created_at\" field.",
"type": "string"
},
"default_group_id": {
"description": "DefaultGroupID holds the value of the \"default_group_id\" field.",
"type": "string"
},
"edges": {
"description": "Edges holds the relations/edges for other nodes in the graph.\nThe values are being populated by the UserQuery when eager-loading is set.",
"allOf": [
@@ -3760,12 +3587,13 @@
"$ref": "#/definitions/ent.AuthTokens"
}
},
"groups": {
"description": "Groups holds the value of the groups edge.",
"type": "array",
"items": {
"$ref": "#/definitions/ent.Group"
}
"group": {
"description": "Group holds the value of the group edge.",
"allOf": [
{
"$ref": "#/definitions/ent.Group"
}
]
},
"notifiers": {
"description": "Notifiers holds the value of the notifiers edge.",
@@ -3859,23 +3687,6 @@
}
}
},
"repo.GroupInvitation": {
"type": "object",
"properties": {
"expiresAt": {
"type": "string"
},
"group": {
"$ref": "#/definitions/repo.Group"
},
"id": {
"type": "string"
},
"uses": {
"type": "integer"
}
}
},
"repo.GroupStatistics": {
"type": "object",
"properties": {
@@ -5093,17 +4904,14 @@
"repo.UserOut": {
"type": "object",
"properties": {
"defaultGroupId": {
"type": "string"
},
"email": {
"type": "string"
},
"groupIds": {
"type": "array",
"items": {
"type": "string"
}
"groupId": {
"type": "string"
},
"groupName": {
"type": "string"
},
"id": {
"type": "string"
@@ -5324,17 +5132,6 @@
}
}
},
"v1.GroupMemberAdd": {
"type": "object",
"required": [
"userId"
],
"properties": {
"userId": {
"type": "string"
}
}
},
"v1.ItemAttachmentToken": {
"type": "object",
"properties": {

View File

@@ -719,9 +719,6 @@ definitions:
created_at:
description: CreatedAt holds the value of the "created_at" field.
type: string
default_group_id:
description: DefaultGroupID holds the value of the "default_group_id" field.
type: string
edges:
allOf:
- $ref: '#/definitions/ent.UserEdges'
@@ -764,11 +761,10 @@ definitions:
items:
$ref: '#/definitions/ent.AuthTokens'
type: array
groups:
description: Groups holds the value of the groups edge.
items:
$ref: '#/definitions/ent.Group'
type: array
group:
allOf:
- $ref: '#/definitions/ent.Group'
description: Group holds the value of the group edge.
notifiers:
description: Notifiers holds the value of the notifiers edge.
items:
@@ -832,17 +828,6 @@ definitions:
updatedAt:
type: string
type: object
repo.GroupInvitation:
properties:
expiresAt:
type: string
group:
$ref: '#/definitions/repo.Group'
id:
type: string
uses:
type: integer
type: object
repo.GroupStatistics:
properties:
totalItemPrice:
@@ -1675,14 +1660,12 @@ definitions:
type: object
repo.UserOut:
properties:
defaultGroupId:
type: string
email:
type: string
groupIds:
items:
type: string
type: array
groupId:
type: string
groupName:
type: string
id:
type: string
isOwner:
@@ -1827,13 +1810,6 @@ definitions:
required:
- uses
type: object
v1.GroupMemberAdd:
properties:
userId:
type: string
required:
- userId
type: object
v1.ItemAttachmentToken:
properties:
token:
@@ -2056,12 +2032,10 @@ paths:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.Group'
type: array
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Get All Groups
summary: Get Group
tags:
- Group
put:
@@ -2084,106 +2058,7 @@ paths:
summary: Update Group
tags:
- Group
/v1/groups/{id}:
delete:
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Delete Group
tags:
- Group
post:
parameters:
- description: Group Name
in: body
name: name
required: true
schema:
type: string
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/repo.Group'
security:
- Bearer: []
summary: Create Group
tags:
- Group
/v1/groups/{id}/members:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.UserOut'
type: array
security:
- Bearer: []
summary: Get All Group Members
tags:
- Group
post:
parameters:
- description: User ID
in: body
name: payload
required: true
schema:
$ref: '#/definitions/v1.GroupMemberAdd'
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Add User to Group
tags:
- Group
/v1/groups/{id}/members/{user_id}:
delete:
parameters:
- description: User ID
in: path
name: user_id
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
security:
- Bearer: []
summary: Remove User from Group
tags:
- Group
/v1/groups/invitations:
get:
produces:
- application/json
responses:
"200":
description: OK
schema:
items:
$ref: '#/definitions/repo.GroupInvitation'
type: array
security:
- Bearer: []
summary: Get All Group Invitations
tags:
- Group
post:
parameters:
- description: User Data

View File

@@ -7,16 +7,12 @@ describe("first time user workflow (register, login, join group)", () => {
test("user should be able to update group", async () => {
const { client } = await factories.client.singleUse();
const { data: user } = await client.user.self();
const name = faker.person.firstName();
const { response, data: group } = await client.group.update(
{
name,
currency: "eur",
},
user.item.defaultGroupId
);
const { response, data: group } = await client.group.update({
name,
currency: "eur",
});
expect(response.status).toBe(200);
expect(group.name).toBe(name);
@@ -25,8 +21,7 @@ describe("first time user workflow (register, login, join group)", () => {
test("user should be able to get own group", async () => {
const { client } = await factories.client.singleUse();
const { data: user } = await client.user.self();
const { response, data: group } = await client.group.get(user.item.defaultGroupId);
const { response, data: group } = await client.group.get();
expect(response.status).toBe(200);
expect(group.name).toBeTruthy();
@@ -62,7 +57,7 @@ describe("first time user workflow (register, login, join group)", () => {
const client2 = factories.client.user(loginData.token);
const { data: user2 } = await client2.user.self();
expect(user2.item.defaultGroupId).toBe(user1.item.defaultGroupId);
user2.item.groupName = user1.item.groupName;
// Cleanup User 2
const { response: deleteResp } = await client2.user.delete();

View File

@@ -15,16 +15,16 @@ export class GroupApi extends BaseAPI {
});
}
update(data: GroupUpdate, groupId?: string) {
update(data: GroupUpdate) {
return this.http.put<GroupUpdate, Group>({
url: route(`/groups/${groupId || ""}`),
url: route("/groups"),
body: data,
});
}
get(groupId?: string) {
get() {
return this.http.get<Group>({
url: route(`/groups/${groupId || ""}`),
url: route("/groups"),
});
}

View File

@@ -508,8 +508,6 @@ export interface EntUser {
activated_on: string;
/** CreatedAt holds the value of the "created_at" field. */
created_at: string;
/** DefaultGroupID holds the value of the "default_group_id" field. */
default_group_id: string;
/**
* Edges holds the relations/edges for other nodes in the graph.
* The values are being populated by the UserQuery when eager-loading is set.
@@ -538,8 +536,8 @@ export interface EntUser {
export interface EntUserEdges {
/** AuthTokens holds the value of the auth_tokens edge. */
auth_tokens: EntAuthTokens[];
/** Groups holds the value of the groups edge. */
groups: EntGroup[];
/** Group holds the value of the group edge. */
group: EntGroup;
/** Notifiers holds the value of the notifiers edge. */
notifiers: EntNotifier[];
}
@@ -572,13 +570,6 @@ export interface Group {
updatedAt: Date | string;
}
export interface GroupInvitation {
expiresAt: Date | string;
group: Group;
id: string;
uses: number;
}
export interface GroupStatistics {
totalItemPrice: number;
totalItems: number;
@@ -1036,9 +1027,9 @@ export interface TreeItem {
}
export interface UserOut {
defaultGroupId: string;
email: string;
groupIds: string[];
groupId: string;
groupName: string;
id: string;
isOwner: boolean;
isSuperuser: boolean;
@@ -1121,10 +1112,6 @@ export interface GroupInvitationCreate {
uses: number;
}
export interface GroupMemberAdd {
userId: string;
}
export interface ItemAttachmentToken {
token: string;
}

View File

@@ -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",

View File

@@ -1,182 +1,129 @@
import type { Page } from "@playwright/test";
import { expect, test } from "@playwright/test";
const STATUS_ROUTE = "**/api/v1/status";
const WIPE_ROUTE = "**/api/v1/actions/wipe-inventory";
const buildStatusResponse = (demo: boolean) => ({
allowRegistration: true,
build: { buildTime: new Date().toISOString(), commit: "test", version: "v0.0.0" },
demo,
health: true,
labelPrinting: false,
latest: { date: new Date().toISOString(), version: "v0.0.0" },
message: "",
oidc: { allowLocal: true, autoRedirect: false, buttonText: "", enabled: false },
title: "Homebox",
versions: [],
});
async function mockStatus(page: Page, demo: boolean) {
await page.route(STATUS_ROUTE, route => {
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(buildStatusResponse(demo)),
});
});
}
async function login(page: Page, email = "demo@example.com", password = "demo") {
await page.goto("/home");
await expect(page).toHaveURL("/");
await page.fill("input[type='text']", email);
await page.fill("input[type='password']", password);
await page.click("button[type='submit']");
await expect(page).toHaveURL("/home");
}
async function openWipeInventory(page: Page) {
await page.goto("/tools");
await page.waitForLoadState("networkidle");
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
const wipeButton = page.getByRole("button", { name: "Wipe Inventory" }).last();
await expect(wipeButton).toBeVisible();
await wipeButton.click();
}
test.describe("Wipe Inventory", () => {
test("shows demo mode warning without wipe options", async ({ page }) => {
await mockStatus(page, true);
await login(page);
await openWipeInventory(page);
await expect(
page.getByText(
"Inventory, labels, locations and maintenance records cannot be wiped whilst Homebox is in demo mode.",
{ exact: false }
)
).toBeVisible();
await expect(page.locator("input#wipe-labels-checkbox")).toHaveCount(0);
await expect(page.locator("input#wipe-locations-checkbox")).toHaveCount(0);
await expect(page.locator("input#wipe-maintenance-checkbox")).toHaveCount(0);
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.describe("production mode", () => {
test.beforeEach(async ({ page }) => {
await mockStatus(page, false);
await login(page);
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");
test("renders wipe options and submits all flags", async ({ page }) => {
await page.route(WIPE_ROUTE, route => {
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ completed: 0 }) });
});
// 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);
await openWipeInventory(page);
await expect(page.getByText("Wipe Inventory").first()).toBeVisible();
// 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();
const labels = page.locator("input#wipe-labels-checkbox");
const locations = page.locator("input#wipe-locations-checkbox");
const maintenance = page.locator("input#wipe-maintenance-checkbox");
await expect(labels).toBeVisible();
await expect(locations).toBeVisible();
await expect(maintenance).toBeVisible();
await labels.check();
await locations.check();
await maintenance.check();
const requestPromise = page.waitForRequest(WIPE_ROUTE);
await page.getByRole("button", { name: "Confirm" }).last().click();
const request = await requestPromise;
expect(request.postDataJSON()).toEqual({
wipeLabels: true,
wipeLocations: true,
wipeMaintenance: true,
});
await expect(page.locator("[role='status']").first()).toBeVisible();
// 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");
test("blocks wipe attempts from non-owners", async ({ page }) => {
await page.route(WIPE_ROUTE, route => {
route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ message: "forbidden" }),
});
});
// Click Confirm button
await confirmButton.click();
await page.waitForTimeout(2000);
await openWipeInventory(page);
// Wait for the dialog to close (verify button is no longer visible)
await expect(confirmButton).not.toBeVisible({ timeout: 5000 });
const requestPromise = page.waitForRequest(WIPE_ROUTE);
await page.getByRole("button", { name: "Confirm" }).last().click();
await requestPromise;
// 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 });
await expect(page.getByText("Failed to wipe inventory.")).toBeVisible();
// 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");
const checkboxCases = [
{
name: "labels only",
selection: { labels: true, locations: false, maintenance: false },
},
{
name: "locations only",
selection: { labels: false, locations: true, maintenance: false },
},
{
name: "maintenance only",
selection: { labels: false, locations: false, maintenance: true },
},
];
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");
});
for (const scenario of checkboxCases) {
test(`submits correct flags when ${scenario.name} is selected`, async ({ page }) => {
await page.route(WIPE_ROUTE, route => {
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ completed: 0 }) });
});
test("should cancel wipe inventory operation", async ({ page }) => {
// Navigate to Tools page
await page.goto("/tools");
await page.waitForLoadState("networkidle");
await openWipeInventory(page);
await expect(page.getByText("Wipe Inventory").first()).toBeVisible();
// Scroll to wipe inventory section
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
await page.waitForTimeout(500);
const labels = page.locator("input#wipe-labels-checkbox");
const locations = page.locator("input#wipe-locations-checkbox");
const maintenance = page.locator("input#wipe-maintenance-checkbox");
// Click Wipe Inventory button
const wipeButton = page.locator("button", { hasText: "Wipe Inventory" }).last();
await wipeButton.click();
await page.waitForTimeout(1000);
if (scenario.selection.labels) {
await labels.check();
} else {
await labels.uncheck();
}
// Verify dialog is open
await expect(page.locator("text=Wipe Inventory").first()).toBeVisible();
if (scenario.selection.locations) {
await locations.check();
} else {
await locations.uncheck();
}
// Click Cancel button
const cancelButton = page.locator("button", { hasText: "Cancel" });
await cancelButton.click();
await page.waitForTimeout(1000);
if (scenario.selection.maintenance) {
await maintenance.check();
} else {
await maintenance.uncheck();
}
// Verify dialog is closed
await expect(page.locator("text=Wipe Inventory").first()).not.toBeVisible({ timeout: 5000 });
const requestPromise = page.waitForRequest(WIPE_ROUTE);
await page.getByRole("button", { name: "Confirm" }).last().click();
const request = await requestPromise;
expect(request.postDataJSON()).toEqual({
wipeLabels: scenario.selection.labels,
wipeLocations: scenario.selection.locations,
wipeMaintenance: scenario.selection.maintenance,
});
});
}
// 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");
});
});