Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@
"@xterm/addon-webgl": "^0.19.0",
"@xterm/xterm": "^6.0.0",
"@xyflow/svelte": "^1.5.0",
"dompurify": "3.4.13",
"dompurify": "3.4.16",
"marked": "^18.0.3",
"mermaid": "^11.16.1",
"react": "^18.3.1",
Expand Down Expand Up @@ -106,7 +106,7 @@
"@milkdown/utils": {
"nanoid": "^5.1.16"
},
"devalue": "^5.9.2",
"devalue": "^5.9.3",
"lodash-es": "^4.18.1",
"postcss": "8.5.23"
}
Expand Down
1 change: 1 addition & 0 deletions frontend/src/lib/mobile/MobileItemDetail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@
if (token !== loadToken) return;
item = { ...item, ...fresh };
} catch (err) {
if (token !== loadToken) return;
// 404 => the item was deleted out from under us; leave the stale view.
if (err?.status === 404) handleDeleted();
return;
Expand Down
52 changes: 46 additions & 6 deletions internal/repository/collection_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ type CollectionRepository struct {
db database.Database
}

// ErrCollectionQueuesRequireWorkspace prevents orphaning queues when a
// collection is moved to the global scope, which has no queue storage scope.
var ErrCollectionQueuesRequireWorkspace = errors.New("collection queues require a workspace")

// NewCollectionRepository creates a collection repository.
func NewCollectionRepository(db database.Database) *CollectionRepository {
return &CollectionRepository{db: db}
Expand Down Expand Up @@ -228,12 +232,48 @@ func (r *CollectionRepository) Create(collection *models.Collection, userID int)

// Update replaces all editable collection fields.
func (r *CollectionRepository) Update(id int, collection *models.Collection) error {
_, err := r.db.ExecWrite(`
UPDATE collections
SET name = ?, description = ?, ql_query = ?, filter_state = ?, is_public = ?, workspace_id = ?, category_id = ?, public_slug = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, collection.Name, collection.Description, collection.QLQuery, collection.FilterState,
collection.IsPublic, collection.WorkspaceID, collection.CategoryID, collection.PublicSlug, id)
err := database.WithTx(r.db, func(tx database.Tx) error {
// Serialize scope changes with queue creation before checking for queues.
// Updating only updated_at locks the row without firing the collection
// change trigger, which watches query, filter, and workspace columns.
var currentWorkspace sql.NullInt64
err := tx.QueryRow(`
UPDATE collections SET updated_at = updated_at WHERE id = ?
RETURNING workspace_id
`, id).Scan(&currentWorkspace)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
if err != nil {
return err
}
if collection.WorkspaceID == nil && currentWorkspace.Valid {
var queueCount int
if err := tx.QueryRow("SELECT COUNT(*) FROM queues WHERE collection_id = ?", id).Scan(&queueCount); err != nil {
return err
}
if queueCount > 0 {
return ErrCollectionQueuesRequireWorkspace
}
}
if _, err := tx.Exec(`
UPDATE collections
SET name = ?, description = ?, ql_query = ?, filter_state = ?, is_public = ?, workspace_id = ?, category_id = ?, public_slug = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ?
`, collection.Name, collection.Description, collection.QLQuery, collection.FilterState,
collection.IsPublic, collection.WorkspaceID, collection.CategoryID, collection.PublicSlug, id); err != nil {
return err
}
if collection.WorkspaceID != nil {
if _, err := tx.Exec(`
UPDATE queues SET workspace_id = ?, updated_at = CURRENT_TIMESTAMP
WHERE collection_id = ? AND workspace_id != ?
`, *collection.WorkspaceID, id, *collection.WorkspaceID); err != nil {
return err
}
}
return nil
})
if err != nil {
return fmt.Errorf("update collection %d: %w", id, err)
}
Expand Down
75 changes: 69 additions & 6 deletions internal/repository/queue_repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,12 +101,17 @@ func (r *QueueRepository) Create(q *models.Queue) (*models.Queue, error) {
now := time.Now()
q.CreatedAt, q.UpdatedAt = now, now
var id int
err := r.db.QueryRow(`
INSERT INTO queues
(workspace_id, collection_id, name, ql_query, filter_state, position, created_by, builtin_key, is_hidden, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
`, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.FilterState, q.Position,
q.CreatedBy, q.BuiltinKey, q.IsHidden, now, now).Scan(&id)
err := database.WithTx(r.db, func(tx database.Tx) error {
if err := lockQueueCollectionScope(tx, q.WorkspaceID, q.CollectionID); err != nil {
return err
}
return tx.QueryRow(`
INSERT INTO queues
(workspace_id, collection_id, name, ql_query, filter_state, position, created_by, builtin_key, is_hidden, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id
`, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.FilterState, q.Position,
q.CreatedBy, q.BuiltinKey, q.IsHidden, now, now).Scan(&id)
})
if err != nil {
if database.IsUniqueConstraintError(err) {
return nil, ErrDuplicateEntry
Expand All @@ -117,6 +122,64 @@ func (r *QueueRepository) Create(q *models.Queue) (*models.Queue, error) {
return q, nil
}

// lockQueueCollectionScope serializes queue writes with collection scope
// changes. A stale workspace is rejected after any in-flight move commits.
func lockQueueCollectionScope(tx database.Tx, workspaceID int, collectionID *int) error {
if collectionID == nil {
return nil
}
var currentWorkspace int
err := tx.QueryRow(`
UPDATE collections SET updated_at = updated_at
WHERE id = ? AND workspace_id = ? RETURNING workspace_id
`, *collectionID, workspaceID).Scan(&currentWorkspace)
if errors.Is(err, sql.ErrNoRows) {
return ErrNotFound
}
return err
}

// SetBuiltinHidden persists or removes a built-in queue dismissal while
// holding the collection scope lock through the whole operation.
func (r *QueueRepository) SetBuiltinHidden(q *models.Queue, hidden bool) error {
if q == nil || q.BuiltinKey == nil {
return errors.New("built-in queue key is required")
}
return database.WithTx(r.db, func(tx database.Tx) error {
if err := lockQueueCollectionScope(tx, q.WorkspaceID, q.CollectionID); err != nil {
return err
}
where, args := queueScopeClause(q.WorkspaceID, q.CollectionID)
args = append(args, *q.BuiltinKey)
if !hidden {
_, err := tx.Exec("DELETE FROM queues WHERE "+where+" AND builtin_key = ?", args...)
return err
}
var id int
var isHidden bool
err := tx.QueryRow("SELECT id, is_hidden FROM queues WHERE "+where+" AND builtin_key = ?", args...).Scan(&id, &isHidden)
if err == nil {
if isHidden {
return nil
}
_, err = tx.Exec("UPDATE queues SET is_hidden = ?, updated_at = ? WHERE id = ?", true, time.Now(), id)
return err
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
_, err = tx.Exec(`
INSERT INTO queues
(workspace_id, collection_id, name, ql_query, position, created_by, builtin_key, is_hidden, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, CURRENT_TIMESTAMP)
`, q.WorkspaceID, q.CollectionID, q.Name, q.QLQuery, q.Position, q.CreatedBy, q.BuiltinKey, true)
if database.IsUniqueConstraintError(err) {
return ErrDuplicateEntry
}
return err
})
}

// Update overwrites the mutable fields of a queue row.
func (r *QueueRepository) Update(q *models.Queue) error {
q.UpdatedAt = time.Now()
Expand Down
3 changes: 3 additions & 0 deletions internal/services/collection_application_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,9 @@ func (s *CollectionApplicationService) Update(actor AuditActor, id int, update C
}
}
if err := s.repository.Update(id, collection); err != nil {
if errors.Is(err, repository.ErrCollectionQueuesRequireWorkspace) {
return nil, collectionValidation("remove collection queues before clearing its workspace")
}
if database.IsUniqueConstraintError(err) {
return nil, ErrCollectionConflict
}
Expand Down
42 changes: 17 additions & 25 deletions internal/services/item_support_queues.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,31 +223,16 @@ func (s *ItemApplicationService) SetBuiltinQueueHidden(actor AuditActor, workspa
return repository.ErrNotFound
}
repo := repository.NewQueueRepository(s.db)
if !hidden {
return repo.DeleteByBuiltinKey(scope.WorkspaceID, scope.CollectionID, builtinKey)
}
existing, err := repo.GetByBuiltinKey(scope.WorkspaceID, scope.CollectionID, builtinKey)
if err == nil {
if existing.IsHidden {
return nil
}
existing.IsHidden = true
return repo.Update(existing)
}
if !errors.Is(err, repository.ErrNotFound) {
return err
}
createdBy := actor.UserID
_, err = repo.Create(&models.Queue{
err = repo.SetBuiltinHidden(&models.Queue{
WorkspaceID: scope.WorkspaceID,
CollectionID: scope.CollectionID,
Name: preset.Name,
QLQuery: preset.QL,
BuiltinKey: &preset.Key,
IsHidden: true,
Position: builtinQueuePosition(preset.Key),
CreatedBy: &createdBy,
})
}, hidden)
if errors.Is(err, repository.ErrDuplicateEntry) {
return nil
}
Expand Down Expand Up @@ -367,20 +352,20 @@ func (s *ItemApplicationService) queueScope(workspaceID int, collectionID *int)
return QueueScope{}, err
}
if collection.WorkspaceID == nil {
return QueueScope{}, queueValidation("collection is not bound to a workspace")
return QueueScope{}, repository.ErrNotFound
}
return QueueScope{WorkspaceID: *collection.WorkspaceID, CollectionID: collectionID}, nil
}

func (s *ItemApplicationService) authorizeQueueRead(userID int, scope QueueScope) error {
allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView)
if err != nil {
return err
}
if !allowed {
return repository.ErrNotFound
}
if scope.CollectionID == nil {
allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView)
if err != nil {
return err
}
if !allowed {
return repository.ErrNotFound
}
return nil
}
collection, err := repository.NewCollectionRepository(s.db).GetByID(*scope.CollectionID)
Expand All @@ -404,6 +389,13 @@ func (s *ItemApplicationService) authorizeQueueWrite(userID int, scope QueueScop
}
return nil
}
allowed, err := s.perm.HasWorkspacePermission(userID, scope.WorkspaceID, models.PermissionItemView)
if err != nil {
return err
}
if !allowed {
return repository.ErrNotFound
}
collection, err := repository.NewCollectionRepository(s.db).GetByID(*scope.CollectionID)
if err != nil {
return err
Expand Down
10 changes: 10 additions & 0 deletions internal/services/zammad_service.go
Original file line number Diff line number Diff line change
Expand Up @@ -1938,6 +1938,16 @@ func (s *ZammadService) completeWindshiftItem(ctx context.Context, link *models.
if err != nil {
return false, err
}
actor, err := repository.NewUserRepository(s.db).GetActivationTarget(*connection.CreatedBy)
if errors.Is(err, repository.ErrNotFound) {
return false, errors.New("configured Zammad actor no longer exists")
}
if err != nil {
return false, err
}
if !actor.IsActive || actor.Offboarded {
return false, errors.New("configured Zammad actor is inactive or offboarded")
}
allowed, err := s.permission.HasWorkspacePermission(*connection.CreatedBy, item.WorkspaceID, models.PermissionItemEdit)
if err != nil {
return false, err
Expand Down
Loading