mirror of
https://github.com/btouchard/ackify.git
synced 2026-05-24 02:11:12 -05:00
Merge branch 'feat/telemetry'
This commit is contained in:
+2
-1
@@ -68,4 +68,5 @@ ACKIFY_ED25519_PRIVATE_KEY=your_base64_encoded_ed25519_private_key
|
||||
# ACKIFY_ONLY_ADMIN_CAN_CREATE=false
|
||||
|
||||
# Server Configuration
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
ACKIFY_LISTEN_ADDR=:8080
|
||||
ACKIFY_TELEMETRY=false
|
||||
@@ -42,7 +42,8 @@ func main() {
|
||||
logger.Logger.Info("Starting Ackify Community Edition",
|
||||
"version", Version,
|
||||
"commit", Commit,
|
||||
"build_date", BuildDate)
|
||||
"build_date", BuildDate,
|
||||
"telemetry", cfg.Telemetry)
|
||||
|
||||
// Initialize DB
|
||||
db, err := database.InitDB(ctx, database.Config{DSN: cfg.Database.DSN})
|
||||
|
||||
@@ -468,6 +468,15 @@ func (s *DocumentService) Count(ctx context.Context, searchQuery string) (int, e
|
||||
return s.repo.Count(ctx, searchQuery)
|
||||
}
|
||||
|
||||
// CountDocs returns the current count of documents
|
||||
func (s *DocumentService) CountDocs(ctx context.Context) int {
|
||||
c, err := s.repo.Count(ctx, "")
|
||||
if err != nil {
|
||||
c = 0
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// GetByDocID retrieves a document by its ID
|
||||
func (s *DocumentService) GetByDocID(ctx context.Context, docID string) (*models.Document, error) {
|
||||
return s.repo.GetByDocID(ctx, docID)
|
||||
|
||||
@@ -26,6 +26,7 @@ type asyncReminderRepository interface {
|
||||
LogReminder(ctx context.Context, log *models.ReminderLog) error
|
||||
GetReminderHistory(ctx context.Context, docID string) ([]*models.ReminderLog, error)
|
||||
GetReminderStats(ctx context.Context, docID string) (*models.ReminderStats, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// asyncMagicLinkService defines magic link operations for async reminders
|
||||
@@ -305,3 +306,12 @@ func (s *ReminderAsyncService) SendReminders(
|
||||
) (*models.ReminderSendResult, error) {
|
||||
return s.SendRemindersAsync(ctx, docID, sentBy, specificEmails, docURL, locale)
|
||||
}
|
||||
|
||||
// CountSent returns the number of sent reminders for a document
|
||||
func (s *ReminderAsyncService) CountSent(ctx context.Context) int {
|
||||
c, err := s.reminderRepo.Count(ctx)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ type repository interface {
|
||||
GetLastSignature(ctx context.Context, docID string) (*models.Signature, error)
|
||||
GetAllSignaturesOrdered(ctx context.Context) ([]*models.Signature, error)
|
||||
UpdatePrevHash(ctx context.Context, id int64, prevHash *string) error
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
type cryptoSigner interface {
|
||||
@@ -442,3 +443,12 @@ func (s *SignatureService) verifyDocumentIntegrity(ctx context.Context, doc *mod
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CountSigns returns the current count of signatures in the database
|
||||
func (s *SignatureService) CountSigns(ctx context.Context) int {
|
||||
c, err := s.repo.Count(ctx)
|
||||
if err != nil {
|
||||
c = 0
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ func (m *mockSignatureRepository) UpdatePrevHash(ctx context.Context, id int64,
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *mockSignatureRepository) Count(ctx context.Context) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// mockCryptoSigner for testing
|
||||
type mockCryptoSigner struct{}
|
||||
|
||||
|
||||
@@ -150,6 +150,10 @@ func (f *fakeRepository) UpdatePrevHash(_ context.Context, id int64, prevHash *s
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRepository) Count(_ context.Context) (int, error) {
|
||||
return len(f.allSignatures), nil
|
||||
}
|
||||
|
||||
type fakeCryptoSigner struct {
|
||||
shouldFail bool
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
package telemetry
|
||||
@@ -17,6 +17,7 @@ type webhookRepository interface {
|
||||
GetByID(ctx context.Context, id int64) (*models.Webhook, error)
|
||||
List(ctx context.Context, limit, offset int) ([]*models.Webhook, error)
|
||||
ListActiveByEvent(ctx context.Context, event string) ([]*models.Webhook, error)
|
||||
Count(ctx context.Context) (int, error)
|
||||
}
|
||||
|
||||
// webhookDeliveryRepository defines webhook delivery operations
|
||||
@@ -87,3 +88,12 @@ func (s *WebhookService) ListDeliveries(ctx context.Context, webhookID int64, li
|
||||
func (s *WebhookService) EnqueueDelivery(ctx context.Context, input models.WebhookDeliveryInput) (*models.WebhookDelivery, error) {
|
||||
return s.deliveryRepo.Enqueue(ctx, input)
|
||||
}
|
||||
|
||||
// CountWebhooks returns the total number of webhooks
|
||||
func (s *WebhookService) CountWebhooks(ctx context.Context) int {
|
||||
c, err := s.webhookRepo.Count(ctx)
|
||||
if err != nil {
|
||||
c = 0
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -443,7 +443,6 @@ func (r *DocumentRepository) Search(ctx context.Context, query string, limit, of
|
||||
}
|
||||
|
||||
// Count returns the total number of documents matching the optional search query (excluding soft-deleted)
|
||||
// RLS policy automatically filters by tenant_id
|
||||
func (r *DocumentRepository) Count(ctx context.Context, searchQuery string) (int, error) {
|
||||
var query string
|
||||
var args []interface{}
|
||||
|
||||
@@ -190,3 +190,14 @@ func (r *ReminderRepository) GetReminderStats(ctx context.Context, docID string)
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// Count returns the number of sent reminders in the database
|
||||
func (r *ReminderRepository) Count(ctx context.Context) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM reminder_logs WHERE status = 'sent'`
|
||||
var count int
|
||||
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count sent reminders count: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -320,3 +320,14 @@ func (r *SignatureRepository) UpdatePrevHash(ctx context.Context, id int64, prev
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Count returns the total number of unique signers (distinct email addresses)
|
||||
func (r *SignatureRepository) Count(ctx context.Context) (int, error) {
|
||||
query := `SELECT COUNT(DISTINCT user_email) FROM signatures`
|
||||
var count int
|
||||
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count unique signers: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -222,3 +222,14 @@ func (r *WebhookRepository) ListActiveByEvent(ctx context.Context, event string)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Count returns the total number of unique webhooks (distinct target URLs)
|
||||
func (r *WebhookRepository) Count(ctx context.Context) (int, error) {
|
||||
query := `SELECT COUNT(DISTINCT target_url) FROM webhooks WHERE active = TRUE`
|
||||
var count int
|
||||
err := dbctx.GetQuerier(ctx, r.db).QueryRowContext(ctx, query).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to count unique webhooks: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
@@ -12,14 +12,15 @@ import (
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
App AppConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Checksum ChecksumConfig
|
||||
Auth AuthConfig
|
||||
OAuth OAuthConfig
|
||||
Mail MailConfig
|
||||
Logger LoggerConfig
|
||||
App AppConfig
|
||||
Server ServerConfig
|
||||
Database DatabaseConfig
|
||||
Checksum ChecksumConfig
|
||||
Auth AuthConfig
|
||||
OAuth OAuthConfig
|
||||
Mail MailConfig
|
||||
Logger LoggerConfig
|
||||
Telemetry bool
|
||||
}
|
||||
|
||||
type AuthConfig struct {
|
||||
@@ -63,11 +64,6 @@ type ServerConfig struct {
|
||||
ListenAddr string
|
||||
}
|
||||
|
||||
type LoggerConfig struct {
|
||||
Level string
|
||||
Format string // "classic" or "json"
|
||||
}
|
||||
|
||||
type MailConfig struct {
|
||||
Host string
|
||||
Port int
|
||||
@@ -93,6 +89,11 @@ type ChecksumConfig struct {
|
||||
InsecureSkipVerify bool // For testing only - DO NOT use in production
|
||||
}
|
||||
|
||||
type LoggerConfig struct {
|
||||
Level string
|
||||
Format string // "classic" or "json"
|
||||
}
|
||||
|
||||
// Load loads configuration from environment variables
|
||||
func Load() (*Config, error) {
|
||||
config := &Config{}
|
||||
@@ -256,6 +257,9 @@ func Load() (*Config, error) {
|
||||
// CSV import configuration
|
||||
config.App.ImportMaxSigners = getEnvInt("ACKIFY_IMPORT_MAX_SIGNERS", 500)
|
||||
|
||||
// Telemetry configuration
|
||||
config.Telemetry = getEnv("ACKIFY_TELEMETRY", "false") != "false" && getEnv("DO_NOT_TRACK", "") != "1"
|
||||
|
||||
// Validation: At least one authentication method must be enabled
|
||||
if !config.Auth.OAuthEnabled && !config.Auth.MagicLinkEnabled {
|
||||
return nil, fmt.Errorf("at least one authentication method must be enabled: set ACKIFY_OAUTH_CLIENT_ID/CLIENT_SECRET for OAuth or ACKIFY_MAIL_HOST for MagicLink")
|
||||
|
||||
@@ -27,6 +27,8 @@ import (
|
||||
"github.com/btouchard/ackify-ce/backend/internal/presentation/handlers"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/crypto"
|
||||
"github.com/btouchard/ackify-ce/backend/pkg/logger"
|
||||
|
||||
sdk "github.com/btouchard/shm/sdk/golang"
|
||||
)
|
||||
|
||||
// Server represents the HTTP server with all its dependencies.
|
||||
@@ -86,7 +88,7 @@ type ServerBuilder struct {
|
||||
magicLinkEnabled bool
|
||||
}
|
||||
|
||||
// NewServerBuilder creates a new server builder with required configuration.
|
||||
// NewServerBuilder creates a new server builder with the required configuration.
|
||||
func NewServerBuilder(cfg *config.Config, frontend embed.FS, version string) *ServerBuilder {
|
||||
return &ServerBuilder{
|
||||
cfg: cfg,
|
||||
@@ -214,6 +216,11 @@ func (b *ServerBuilder) Build(ctx context.Context) (*Server, error) {
|
||||
// Create repositories
|
||||
repos := b.createRepositories()
|
||||
|
||||
// Initialize Telemetry if is enabled
|
||||
if err := b.initializeTelemetry(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Initialize workers and services
|
||||
whPublisher, whWorker, err := b.initializeWebhookSystem(repos)
|
||||
if err != nil {
|
||||
@@ -348,6 +355,29 @@ func (b *ServerBuilder) createRepositories() *repositories {
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ServerBuilder) initializeTelemetry(ctx context.Context) error {
|
||||
telemetry, err := sdk.New(sdk.Config{
|
||||
ServerURL: "https://metrics.kolapsis.com",
|
||||
AppName: "Ackify",
|
||||
AppVersion: b.version,
|
||||
Environment: "production",
|
||||
Enabled: b.cfg.Telemetry,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
telemetry.SetProvider(func() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"documents": b.documentService.CountDocs(ctx),
|
||||
"confirmations": b.signatureService.CountSigns(ctx),
|
||||
"webhooks": b.webhookService.CountWebhooks(ctx),
|
||||
"reminds_sent": b.reminderService.CountSent(ctx),
|
||||
}
|
||||
})
|
||||
go telemetry.Start(context.Background())
|
||||
return nil
|
||||
}
|
||||
|
||||
// initializeWebhookSystem initializes webhook publisher and worker.
|
||||
func (b *ServerBuilder) initializeWebhookSystem(repos *repositories) (*services.WebhookPublisher, *webhook.Worker, error) {
|
||||
whPublisher := services.NewWebhookPublisher(repos.webhook, repos.webhookDelivery)
|
||||
|
||||
@@ -52,6 +52,7 @@ services:
|
||||
ACKIFY_AUTH_RATE_LIMIT: "1000"
|
||||
ACKIFY_DOCUMENT_RATE_LIMIT: "1000"
|
||||
ACKIFY_GENERAL_RATE_LIMIT: "1000"
|
||||
ACKIFY_TELEMETRY: false
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -45,6 +45,7 @@ services:
|
||||
ACKIFY_MAIL_STARTTLS: "false"
|
||||
ACKIFY_MAIL_FROM: "${ACKIFY_MAIL_FROM:-noreply@ackify.local}"
|
||||
ACKIFY_MAIL_FROM_NAME: "${ACKIFY_MAIL_FROM_NAME:-Ackify}"
|
||||
ACKIFY_TELEMETRY: "${ACKIFY_TELEMETRY:-false}"
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -3,6 +3,7 @@ module github.com/btouchard/ackify-ce
|
||||
go 1.24.5
|
||||
|
||||
require (
|
||||
github.com/btouchard/shm v1.2.0
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-mail/mail/v2 v2.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.19.0
|
||||
|
||||
@@ -2,6 +2,8 @@ github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25
|
||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/btouchard/shm v1.2.0 h1:4KsVAWhbvvLnTKEagvbt47DxwwBq8PvglW8Tlsg+bFc=
|
||||
github.com/btouchard/shm v1.2.0/go.mod h1:9+E/t1eveTZwmXGnsTqBn7HeckuQfp7OyCHLE64uS/A=
|
||||
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
|
||||
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
|
||||
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
|
||||
|
||||
@@ -110,6 +110,16 @@ ACKIFY_OAUTH_CLIENT_SECRET=your_oauth_client_secret
|
||||
# When enabled, only admins can create new documents
|
||||
# ACKIFY_ONLY_ADMIN_CAN_CREATE=false
|
||||
|
||||
# ==========================================
|
||||
# Telemetry Configuration
|
||||
# ==========================================
|
||||
# Anonymous usage metrics to help improve Ackify
|
||||
# - GDPR compliant (no personal data collected)
|
||||
# - Business metrics only: documents, signatures, webhooks, reminders count
|
||||
# - Non-intrusive (background collection)
|
||||
# Set to true to help us improve Ackify!
|
||||
ACKIFY_TELEMETRY=false
|
||||
|
||||
# ==========================================
|
||||
# CONFIGURATION INSTRUCTIONS
|
||||
# ==========================================
|
||||
|
||||
@@ -144,6 +144,40 @@ MagicLink provides passwordless authentication via email. Users receive a secure
|
||||
- Internal applications where email domain is trusted
|
||||
- Combination with OAuth for flexible authentication
|
||||
|
||||
## Anonymous Telemetry
|
||||
|
||||
Ackify can collect anonymous usage metrics to help improve the project.
|
||||
|
||||
### What is collected
|
||||
|
||||
**Business metrics only:**
|
||||
- Number of documents created
|
||||
- Number of signatures/confirmations
|
||||
- Number of webhooks configured
|
||||
- Number of email reminders sent
|
||||
|
||||
### What is NOT collected
|
||||
|
||||
- No personal data
|
||||
- No user information (names, emails, IPs)
|
||||
- No document content
|
||||
- No authentication details
|
||||
|
||||
### Privacy
|
||||
|
||||
- **GDPR compliant** - No personal data is ever collected
|
||||
- **Non-intrusive** - Runs in background, no impact on performance
|
||||
- **Opt-in** - Disabled by default, you choose to enable it
|
||||
|
||||
### Configuration
|
||||
|
||||
```env
|
||||
# Enable anonymous telemetry (default: false)
|
||||
ACKIFY_TELEMETRY=true
|
||||
```
|
||||
|
||||
We encourage you to enable telemetry to help us improve Ackify for everyone!
|
||||
|
||||
## SMTP Configuration
|
||||
|
||||
SMTP is used for:
|
||||
@@ -193,6 +227,7 @@ ADMIN_EMAILS=admin@your-domain.com
|
||||
- `MAIL_*` - SMTP configuration for email features
|
||||
- `AUTH_MAGICLINK_ENABLED` - Force enable/disable MagicLink
|
||||
- `ONLY_ADMIN_CAN_CREATE` - Restrict document creation to admins only (default: false)
|
||||
- `ACKIFY_TELEMETRY` - Enable anonymous usage metrics (default: false)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -68,6 +68,7 @@ services:
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS: "${ACKIFY_CHECKSUM_MAX_REDIRECTS:-3}"
|
||||
ACKIFY_CHECKSUM_ALLOWED_TYPES: "${ACKIFY_CHECKSUM_ALLOWED_TYPES:-}"
|
||||
ACKIFY_IMPORT_MAX_SIGNERS: "${ACKIFY_IMPORT_MAX_SIGNERS:-500}"
|
||||
ACKIFY_TELEMETRY: "${ACKIFY_TELEMETRY:-false}"
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -68,6 +68,7 @@ services:
|
||||
ACKIFY_CHECKSUM_MAX_REDIRECTS: "${ACKIFY_CHECKSUM_MAX_REDIRECTS:-3}"
|
||||
ACKIFY_CHECKSUM_ALLOWED_TYPES: "${ACKIFY_CHECKSUM_ALLOWED_TYPES:-}"
|
||||
ACKIFY_IMPORT_MAX_SIGNERS: "${ACKIFY_IMPORT_MAX_SIGNERS:-500}"
|
||||
ACKIFY_TELEMETRY: "${ACKIFY_TELEMETRY:-false}"
|
||||
depends_on:
|
||||
ackify-migrate:
|
||||
condition: service_completed_successfully
|
||||
|
||||
@@ -306,6 +306,41 @@ if [ "$ENABLE_OAUTH" = false ] && [ "$ENABLE_MAGICLINK" = false ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ==========================================
|
||||
# Telemetry Configuration
|
||||
# ==========================================
|
||||
print_header "📊 Anonymous Telemetry"
|
||||
echo ""
|
||||
print_info "Ackify can collect anonymous usage metrics to help improve the project."
|
||||
echo ""
|
||||
print_info "What is collected (business metrics only):"
|
||||
print_info " - Number of documents created"
|
||||
print_info " - Number of signatures/confirmations"
|
||||
print_info " - Number of webhooks configured"
|
||||
print_info " - Number of email reminders sent"
|
||||
echo ""
|
||||
print_info "What is NOT collected:"
|
||||
print_info " - No personal data"
|
||||
print_info " - No user information"
|
||||
print_info " - No document content"
|
||||
print_info " - No email addresses"
|
||||
print_info " - No IP addresses"
|
||||
echo ""
|
||||
print_info "This telemetry is:"
|
||||
print_success " ✓ GDPR compliant"
|
||||
print_success " ✓ Non-intrusive (background only)"
|
||||
print_success " ✓ Helps us improve Ackify for everyone"
|
||||
echo ""
|
||||
|
||||
ENABLE_TELEMETRY=false
|
||||
if prompt_yes_no "Enable anonymous telemetry to help improve Ackify?" "y"; then
|
||||
ENABLE_TELEMETRY=true
|
||||
print_success "Thank you for helping improve Ackify!"
|
||||
else
|
||||
print_info "Telemetry disabled. You can enable it later in .env (ACKIFY_TELEMETRY=true)"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ==========================================
|
||||
# Admin Configuration
|
||||
# ==========================================
|
||||
@@ -498,6 +533,16 @@ APP_DNS=${APP_DNS}
|
||||
EOF
|
||||
fi
|
||||
|
||||
# Telemetry configuration
|
||||
cat >> .env <<EOF
|
||||
# ==========================================
|
||||
# Telemetry Configuration
|
||||
# ==========================================
|
||||
# Anonymous usage metrics (GDPR compliant, no personal data)
|
||||
ACKIFY_TELEMETRY=${ENABLE_TELEMETRY}
|
||||
|
||||
EOF
|
||||
|
||||
print_success ".env file created successfully"
|
||||
echo ""
|
||||
|
||||
@@ -548,6 +593,13 @@ else
|
||||
fi
|
||||
echo ""
|
||||
|
||||
if [ "$ENABLE_TELEMETRY" = true ]; then
|
||||
print_success "Telemetry: Enabled (thank you!)"
|
||||
else
|
||||
print_info "Telemetry: Disabled"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
# ==========================================
|
||||
# Next Steps
|
||||
# ==========================================
|
||||
|
||||
Reference in New Issue
Block a user