feat: role-based permissions (owner/partner/associate/paralegal/secretary)
Backend: - auth/permissions.go: full permission matrix with RequirePermission/RequireRole middleware, CanEditCase, CanDeleteDocument helpers - auth/context.go: add user role to request context - auth/middleware.go: resolve role alongside tenant in auth flow - auth/tenant_resolver.go: verify membership + resolve role for X-Tenant-ID - handlers/case_assignments.go: CRUD for case-level user assignments - handlers/tenant_handler.go: UpdateMemberRole, GetMe (/api/me) endpoints - handlers/documents.go: permission-based delete (own vs all) - router/router.go: permission-wrapped routes for all endpoints - services/case_assignment_service.go: assign/unassign with tenant validation - services/tenant_service.go: UpdateMemberRole with owner protection - models/case_assignment.go: CaseAssignment model Database: - user_tenants.role: CHECK constraint (owner/partner/associate/paralegal/secretary) - case_assignments table: case_id, user_id, role (lead/team/viewer) - Migrated existing admin->partner, member->associate Frontend: - usePermissions hook: fetches /api/me, provides can() helper - TeamSettings: 5-role dropdown, role change, permission-gated invite - CaseAssignments: new component for case-level team management - Sidebar: conditionally hides AI/Settings based on permissions - Cases page: hides "Neue Akte" button for non-authorized roles - Case detail: new "Mitarbeiter" tab for assignment management
This commit is contained in:
92
backend/internal/services/case_assignment_service.go
Normal file
92
backend/internal/services/case_assignment_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/KanzlAI-mGMT/internal/models"
|
||||
)
|
||||
|
||||
type CaseAssignmentService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
func NewCaseAssignmentService(db *sqlx.DB) *CaseAssignmentService {
|
||||
return &CaseAssignmentService{db: db}
|
||||
}
|
||||
|
||||
// ListByCase returns all assignments for a case.
|
||||
func (s *CaseAssignmentService) ListByCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.CaseAssignment, error) {
|
||||
var assignments []models.CaseAssignment
|
||||
err := s.db.SelectContext(ctx, &assignments,
|
||||
`SELECT ca.id, ca.case_id, ca.user_id, ca.role, ca.assigned_at
|
||||
FROM case_assignments ca
|
||||
JOIN cases c ON c.id = ca.case_id
|
||||
WHERE ca.case_id = $1 AND c.tenant_id = $2
|
||||
ORDER BY ca.assigned_at`,
|
||||
caseID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list case assignments: %w", err)
|
||||
}
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// Assign adds a user to a case with the given role.
|
||||
func (s *CaseAssignmentService) Assign(ctx context.Context, tenantID, caseID, userID uuid.UUID, role string) (*models.CaseAssignment, error) {
|
||||
// Verify user is a member of this tenant
|
||||
var memberExists bool
|
||||
err := s.db.GetContext(ctx, &memberExists,
|
||||
`SELECT EXISTS(SELECT 1 FROM user_tenants WHERE user_id = $1 AND tenant_id = $2)`,
|
||||
userID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check membership: %w", err)
|
||||
}
|
||||
if !memberExists {
|
||||
return nil, fmt.Errorf("user is not a member of this tenant")
|
||||
}
|
||||
|
||||
// Verify case belongs to tenant
|
||||
var caseExists bool
|
||||
err = s.db.GetContext(ctx, &caseExists,
|
||||
`SELECT EXISTS(SELECT 1 FROM cases WHERE id = $1 AND tenant_id = $2)`,
|
||||
caseID, tenantID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check case: %w", err)
|
||||
}
|
||||
if !caseExists {
|
||||
return nil, fmt.Errorf("case not found")
|
||||
}
|
||||
|
||||
var assignment models.CaseAssignment
|
||||
err = s.db.QueryRowxContext(ctx,
|
||||
`INSERT INTO case_assignments (case_id, user_id, role)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT (case_id, user_id) DO UPDATE SET role = EXCLUDED.role
|
||||
RETURNING id, case_id, user_id, role, assigned_at`,
|
||||
caseID, userID, role,
|
||||
).StructScan(&assignment)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("assign user to case: %w", err)
|
||||
}
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
// Unassign removes a user from a case.
|
||||
func (s *CaseAssignmentService) Unassign(ctx context.Context, tenantID, caseID, userID uuid.UUID) error {
|
||||
result, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM case_assignments ca
|
||||
USING cases c
|
||||
WHERE ca.case_id = c.id AND ca.case_id = $1 AND ca.user_id = $2 AND c.tenant_id = $3`,
|
||||
caseID, userID, tenantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("unassign: %w", err)
|
||||
}
|
||||
rows, _ := result.RowsAffected()
|
||||
if rows == 0 {
|
||||
return fmt.Errorf("assignment not found")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user