Files
databasus/AGENTS.md

47 KiB
Raw Blame History

Agent Rules and Guidelines

This document contains all coding standards, conventions and best practices recommended for the TgTaps project. This is NOT a strict set of rules, but a set of recommendations to help you write better code.


Table of Contents


Engineering philosophy

Think like a skeptical senior engineer and code reviewer. Don't just do what was asked—also think about what should have been asked.

⚠️ Balance vigilance with pragmatism: Catch real issues, not theoretical ones. Don't let perfect be the enemy of good.

Task context assessment:

First, assess the task scope:

  • Trivial (typos, formatting, simple field adds): Apply directly with minimal analysis
  • Standard (CRUD, typical features): Brief assumption check, proceed
  • Complex (architecture, security, performance-critical): Full analysis required
  • Unclear (ambiguous requirements): Always clarify assumptions first

For non-trivial tasks:

  1. Restate the objective and list assumptions (explicit + implicit)

    • If any assumption is shaky, call it out clearly
    • Distinguish between what's specified and what you're inferring
  2. Propose appropriate solutions:

    • For complex tasks: 23 viable approaches (including a simpler baseline)
    • Recommend one with clear tradeoffs
    • Consider: complexity, maintainability, performance, future extensibility
  3. Identify risks proactively:

    • Edge cases and boundary conditions
    • Security/privacy pitfalls
    • Performance risks and scalability concerns
    • Operational concerns (deployment, observability, rollback, monitoring)
  4. Handle ambiguity:

    • If requirements are ambiguous, make a reasonable default and proceed
    • Clearly label your assumptions
    • Document what would change under alternative assumptions
  5. Deliver quality:

    • Provide a solution that is correct, testable, and maintainable
    • Include minimal tests or validation steps
    • Follow project testing philosophy: prefer controller tests over unit tests
    • Follow all project guidelines from this document
  6. Self-review before finalizing:

    • Ask: "What could go wrong?"
    • Patch the answer accordingly
    • Verify edge cases are handled
  7. Fix the reason, not the symptom:

    • If you find a bug or issue, ask "Why did this happen?" and fix the root cause
    • Avoid quick fixes that don't address underlying problems

Application guidelines:

Scale your response to the task:

  • Trivial changes: Steps 5-6 only (deliver quality + self-review)
  • Standard features: Steps 1, 5-6 (restate + deliver + review)
  • Complex/risky changes: All steps 1-6
  • Ambiguous requests: Steps 1, 4 mandatory

Be proportionally thorough—brief for simple tasks, comprehensive for risky ones. Avoid analysis paralysis.


Backend guidelines

Naming

Variables and functions naming are the most important part of code readability. Always choose descriptive and meaningful names that clearly indicate the purpose and intent of the code.

Avoid abbreviations, unless they are widely accepted and unambiguous (e.g., ID, URL, HTTP). Use consistent naming conventions across the codebase.

Do not use one-two letters. For example:

Bad:

    u := users.getUser()

	pr, pw := io.Pipe()

    r := bufio.NewReader(pr)

Good:

    user := users.GetUser()

    pipeReader, pipeWriter := io.Pipe()

    bufferedReader := bufio.NewReader(pipeReader)

Exclusion: widely used variables like "db", "ctx", "req", "res", etc.

Boolean naming

Always prefix boolean variables with verbs like is, has, was, should, can, etc.

This makes the code more readable and clearly indicates that the variable represents a true/false state.

Good examples:

type User struct {
    IsActive    bool
    IsVerified  bool
    HasAccess   bool
    WasNotified bool
}

type BackupConfig struct {
    IsEnabled       bool
    ShouldCompress  bool
    CanRetry        bool
}

// Variables
isInProgress := true
wasCompleted := false
hasPermission := checkPermissions()

Bad examples:

type User struct {
    Active    bool  // Should be: IsActive
    Verified  bool  // Should be: IsVerified
    Access    bool  // Should be: HasAccess
}

type BackupConfig struct {
    Enabled   bool  // Should be: IsEnabled
    Compress  bool  // Should be: ShouldCompress
    Retry     bool  // Should be: CanRetry
}

// Variables
inProgress := true   // Should be: isInProgress
completed := false   // Should be: wasCompleted
permission := true   // Should be: hasPermission

Common boolean prefixes:

  • is - current state (IsActive, IsValid, IsEnabled)
  • has - possession or presence (HasAccess, HasPermission, HasError)
  • was - past state (WasCompleted, WasNotified, WasDeleted)
  • should - intention or recommendation (ShouldRetry, ShouldCompress)
  • can - capability or permission (CanRetry, CanDelete, CanEdit)
  • will - future state (WillExpire, WillRetry)

Add reasonable new lines between logical statements

Add blank lines between logical blocks to improve code readability.

Separate different logical operations within a function with blank lines. This makes the code flow clearer and helps identify distinct steps in the logic.

Guidelines:

  • Add blank line before final return statement
  • Add blank line after variable declarations before using them
  • Add blank line between error handling and subsequent logic
  • Add blank line between different logical operations

Bad example (without spacing):

func (t *Task) BeforeSave(tx *gorm.DB) error {
	if len(t.Messages) > 0 {
		messagesBytes, err := json.Marshal(t.Messages)
		if err != nil {
			return err
		}
		t.MessagesJSON = string(messagesBytes)
	}
	return nil
}

func (t *Task) AfterFind(tx *gorm.DB) error {
	if t.MessagesJSON != "" {
		var messages []onewin_dto.TaskCompletionMessage
		if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
			return err
		}
		t.Messages = messages
	}
	return nil
}

Good example (with proper spacing):

func (t *Task) BeforeSave(tx *gorm.DB) error {
	if len(t.Messages) > 0 {
		messagesBytes, err := json.Marshal(t.Messages)
		if err != nil {
			return err
		}

		t.MessagesJSON = string(messagesBytes)
	}

	return nil
}

func (t *Task) AfterFind(tx *gorm.DB) error {
	if t.MessagesJSON != "" {
		var messages []onewin_dto.TaskCompletionMessage
		if err := json.Unmarshal([]byte(t.MessagesJSON), &messages); err != nil {
			return err
		}

		t.Messages = messages
	}

	return nil
}

More examples:

Service method with multiple operations:

func (s *UserService) CreateUser(request *CreateUserRequest) (*User, error) {
	// Validate input
	if err := s.validateUserRequest(request); err != nil {
		return nil, err
	}

	// Create user entity
	user := &User{
		ID:    uuid.New(),
		Name:  request.Name,
		Email: request.Email,
	}

	// Save to database
	if err := s.repository.Create(user); err != nil {
		return nil, err
	}

	// Send notification
	s.notificationService.SendWelcomeEmail(user.Email)

	return user, nil
}

Repository method with query building:

func (r *Repository) GetFiltered(filters *Filters) ([]*Entity, error) {
	query := storage.GetDb().Model(&Entity{})

	if filters.Status != "" {
		query = query.Where("status = ?", filters.Status)
	}

	if filters.CreatedAfter != nil {
		query = query.Where("created_at > ?", filters.CreatedAfter)
	}

	var entities []*Entity
	if err := query.Find(&entities).Error; err != nil {
		return nil, err
	}

	return entities, nil
}

Repository method with error handling:

Bad (without spacing):

func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
	var task models.Task
	result := storage.GetDb().Where("id = ?", id).First(&task)
	if result.Error != nil {
		if errors.Is(result.Error, gorm.ErrRecordNotFound) {
			return nil, errors.New("task not found")
		}
		return nil, result.Error
	}
	return &task, nil
}

Good (with proper spacing):

func (r *Repository) FindById(id uuid.UUID) (*models.Task, error) {
	var task models.Task

	result := storage.GetDb().Where("id = ?", id).First(&task)
	if result.Error != nil {
		if errors.Is(result.Error, gorm.ErrRecordNotFound) {
			return nil, errors.New("task not found")
		}

		return nil, result.Error
	}

	return &task, nil
}

Comments

Guidelines

  1. No obvious comments - Don't state what the code already clearly shows
  2. Functions and variables should have meaningful names - Code should be self-documenting
  3. Comments for unclear code only - Only add comments when code logic isn't immediately clear

Key principles:

  • Code should tell a story - Use descriptive variable and function names
  • Comments explain WHY, not WHAT - The code shows what happens, comments explain business logic or complex decisions
  • Prefer refactoring over commenting - If code needs explaining, consider making it clearer instead
  • API documentation is required - Swagger comments for all HTTP endpoints are mandatory
  • Complex algorithms deserve comments - Mathematical formulas, business rules, or non-obvious optimizations
  • Do not write summary sections in .md files unless directly requested - Avoid adding "Summary" or "Conclusion" sections at the end of documentation files unless the user explicitly asks for them

Example of useless comments:

1. Obvious SQL comment:

// Create projects table
CREATE TABLE projects (
    id                    UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name                  TEXT NOT NULL,
    created_at            TIMESTAMPTZ NOT NULL DEFAULT NOW(),

2. Obvious function call comment:

// Create test project
project := CreateTestProject(projectName, user, router)

3. Redundant function comment:

// CreateValidLogItems creates valid log items for testing
func CreateValidLogItems(count int, uniqueID string) []logs_receiving.LogItemRequestDTO {

Controllers

Controller guidelines:

  1. When we write controller:

    • We combine all routes to single controller
    • Names them as .WhatWeDo (not "handlers") concept
  2. We use gin and *gin.Context for all routes

    Example:

    func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ...
    
  3. We document all routes with Swagger in the following format:

package audit_logs

import (
    "net/http"

    user_models "databasus-backend/internal/features/users/models"

    "github.com/gin-gonic/gin"
    "github.com/google/uuid"
)

type AuditLogController struct {
    auditLogService *AuditLogService
}

func (c *AuditLogController) RegisterRoutes(router *gin.RouterGroup) {
    // All audit log endpoints require authentication (handled in main.go)
    auditRoutes := router.Group("/audit-logs")

    auditRoutes.GET("/global", c.GetGlobalAuditLogs)
    auditRoutes.GET("/users/:userId", c.GetUserAuditLogs)
}

// GetGlobalAuditLogs
// @Summary Get global audit logs (ADMIN only)
// @Description Retrieve all audit logs across the system
// @Tags audit-logs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Limit number of results" default(100)
// @Param offset query int false "Offset for pagination" default(0)
// @Param beforeDate query string false "Filter logs created before this date (RFC3339 format)" format(date-time)
// @Success 200 {object} GetAuditLogsResponse
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /audit-logs/global [get]
func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) {
    user, isOk := ctx.MustGet("user").(*user_models.User)
    if !isOk {
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"})
        return
    }

    request := &GetAuditLogsRequest{}
    if err := ctx.ShouldBindQuery(request); err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"})
        return
    }

    response, err := c.auditLogService.GetGlobalAuditLogs(user, request)
    if err != nil {
        if err.Error() == "only administrators can view global audit logs" {
            ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
            return
        }
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"})
        return
    }

    ctx.JSON(http.StatusOK, response)
}

// GetUserAuditLogs
// @Summary Get user audit logs
// @Description Retrieve audit logs for a specific user
// @Tags audit-logs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param userId path string true "User ID"
// @Param limit query int false "Limit number of results" default(100)
// @Param offset query int false "Offset for pagination" default(0)
// @Param beforeDate query string false "Filter logs created before this date (RFC3339 format)" format(date-time)
// @Success 200 {object} GetAuditLogsResponse
// @Failure 400 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /audit-logs/users/{userId} [get]
func (c *AuditLogController) GetUserAuditLogs(ctx *gin.Context) {
    user, isOk := ctx.MustGet("user").(*user_models.User)
    if !isOk {
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"})
        return
    }

    userIDStr := ctx.Param("userId")
    targetUserID, err := uuid.Parse(userIDStr)
    if err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid user ID"})
        return
    }

    request := &GetAuditLogsRequest{}
    if err := ctx.ShouldBindQuery(request); err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"})
        return
    }

    response, err := c.auditLogService.GetUserAuditLogs(targetUserID, user, request)
    if err != nil {
        if err.Error() == "insufficient permissions to view user audit logs" {
            ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
            return
        }
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"})
        return
    }

    ctx.JSON(http.StatusOK, response)
}

Dependency injection (DI)

For DI files use implicit fields declaration styles (especially for controllers, services, repositories, use cases, etc., not simple data structures).

Instead of:

var orderController = &OrderController{
    orderService:   orderService,
    botUserService: bot_users.GetBotUserService(),
    botService:     bots.GetBotService(),
    userService:    users.GetUserService(),
}

Use:

var orderController = &OrderController{
    orderService,
    bot_users.GetBotUserService(),
    bots.GetBotService(),
    users.GetUserService(),
}

This is needed to avoid forgetting to update DI style when we add new dependency.

Force such usage

Please force such usage if file look like this (see some services\controllers\repos definitions and getters):

var orderBackgroundService = &OrderBackgroundService{
    orderService:           orderService,
    orderPaymentRepository: orderPaymentRepository,
    botService:             bots.GetBotService(),
    paymentSettingsService: payment_settings.GetPaymentSettingsService(),

    orderSubscriptionListeners: []OrderSubscriptionListener{},
}

var orderController = &OrderController{
    orderService:   orderService,
    botUserService: bot_users.GetBotUserService(),
    botService:     bots.GetBotService(),
    userService:    users.GetUserService(),
}

func GetUniquePaymentRepository() *repositories.UniquePaymentRepository {
    return uniquePaymentRepository
}

func GetOrderPaymentRepository() *repositories.OrderPaymentRepository {
    return orderPaymentRepository
}

func GetOrderService() *OrderService {
    return orderService
}

func GetOrderController() *OrderController {
    return orderController
}

func GetOrderBackgroundService() *OrderBackgroundService {
    return orderBackgroundService
}

func GetOrderRepository() *repositories.OrderRepository {
    return orderRepository
}

SetupDependencies() pattern

All SetupDependencies() functions must use sync.OnceFunc to ensure idempotent execution.

This pattern allows SetupDependencies() to be safely called multiple times (especially in tests) while ensuring the actual setup logic executes only once.

Implementation pattern:

package feature

import (
    "sync"
)

var SetupDependencies = sync.OnceFunc(func() {
    // Initialize dependencies here
    someService.SetDependency(otherService)
    anotherService.AddListener(listener)
})

Why this pattern:

  • Tests can call multiple times: Test setup often calls SetupDependencies() multiple times without issues
  • Thread-safe: Works correctly with concurrent calls (nanoseconds or seconds apart)
  • Idempotent: Subsequent calls are no-ops
  • No panics: Does not break tests or production code on multiple calls
  • Concise: sync.OnceFunc (Go 1.21+) replaces the manual sync.Once + atomic.Bool + Do() boilerplate

Key Points:

  1. Use sync.OnceFunc instead of manual sync.Once + atomic.Bool pattern
  2. All setup logic must be inside the OnceFunc closure
  3. The returned function is safe to call concurrently and multiple times

Background services

All background service Run() methods must panic if called multiple times to prevent corrupted states.

Background services run infinite loops and must never be started twice on the same instance. Multiple calls indicate a serious bug that would cause duplicate goroutines, resource leaks, and data corruption.

Implementation pattern:

package feature

import (
    "context"
    "fmt"
    "sync"
    "sync/atomic"
)

type BackgroundService struct {
    // ... existing fields ...
    hasRun atomic.Bool
}

func (s *BackgroundService) Run(ctx context.Context) {
    if s.hasRun.Swap(true) {
        panic(fmt.Sprintf("%T.Run() called multiple times", s))
    }

    // Existing infinite loop logic
    ticker := time.NewTicker(1 * time.Minute)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            s.doWork()
        }
    }
}

Why panic instead of warning:

  • Prevents corruption: Multiple Run() calls would create duplicate goroutines consuming resources
  • Fails fast: Catches critical bugs immediately in tests and production
  • Clear indication: Panic clearly indicates a serious programming error
  • Applies everywhere: Same protection in tests and production

When this applies:

  • All background services with infinite loops
  • Registry services (BackupNodesRegistry, RestoreNodesRegistry)
  • Scheduler services (BackupsScheduler, RestoresScheduler)
  • Worker nodes (BackuperNode, RestorerNode)
  • Cleanup services (AuditLogBackgroundService, DownloadTokenBackgroundService)

Key Points:

  1. Use atomic.Bool.Swap(true) to atomically check-and-set in one call — no need for sync.Once
  2. Always panic if already run (never just log warning)
  3. This pattern is thread-safe for any timing (concurrent or sequential calls)

Migrations

When writing migrations:

  • Write them for PostgreSQL
  • For PRIMARY UUID keys use gen_random_uuid()
  • For time use TIMESTAMPTZ (timestamp with zone)
  • Split table, constraint and indexes declaration (table first, then other one by one)
  • Format SQL in pretty way (add spaces, align columns types), constraints split by lines

Example:

CREATE TABLE marketplace_info (
    bot_id            UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    title             TEXT NOT NULL,
    description       TEXT NOT NULL,
    short_description TEXT NOT NULL,
    tutorial_url      TEXT,
    info_order        BIGINT NOT NULL DEFAULT 0,
    is_published      BOOLEAN NOT NULL DEFAULT FALSE
);

ALTER TABLE marketplace_info_images
    ADD CONSTRAINT fk_marketplace_info_images_bot_id
    FOREIGN KEY (bot_id)
    REFERENCES marketplace_info (bot_id);

Refactoring

When applying changes, do not forget to refactor old code.

You can shortify, make more readable, improve code quality, etc. Common logic can be extracted to functions, constants, files, etc.

After each large change with more than ~50-100 lines of code:

  • Always run make lint (from backend root folder)
  • If you change frontend, run npm run format (from frontend root folder)

Testing

After writing tests, always launch them and verify that they pass.

Test naming format

Use these naming patterns:

  • Test_WhatWeDo_WhatWeExpect
  • Test_WhatWeDo_WhichConditions_WhatWeExpect

Examples from real codebase:

  • Test_CreateApiKey_WhenUserIsProjectOwner_ApiKeyCreated
  • Test_UpdateProject_WhenUserIsProjectAdmin_ProjectUpdated
  • Test_DeleteApiKey_WhenUserIsProjectMember_ReturnsForbidden
  • Test_GetProjectAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectly
  • Test_ProjectLifecycleE2E_CompletesSuccessfully

Testing philosophy

Prefer controllers over unit tests:

  • Test through HTTP endpoints via controllers whenever possible
  • Avoid testing repositories, services in isolation - test via API instead
  • Only use unit tests for complex model logic when no API exists
  • Name test files controller_test.go or service_test.go, not integration_test.go

Extract common logic to testing utilities:

  • Create testing.go or testing/testing.go files for shared test utilities
  • Extract router creation, user setup, models creation helpers (in API, not just structs creation)
  • Reuse common patterns across different test files

Refactor existing tests:

  • When working with existing tests, always look for opportunities to refactor and improve
  • Extract repetitive setup code to common utilities
  • Simplify complex tests by breaking them into smaller, focused tests
  • Replace inline test data creation with reusable helper functions
  • Consolidate similar test patterns across different test files
  • Make tests more readable and maintainable for other developers

Clean up test data:

  • If the feature supports cleanup operations (DELETE endpoints, cleanup methods), use them in tests
  • Clean up resources after test execution to avoid test data pollution
  • Use defer statements or explicit cleanup calls at the end of tests
  • Prioritize using API methods for cleanup (not direct database deletion)
  • Examples:
    • CRUD features: delete created records via DELETE endpoint
    • File uploads: remove uploaded files
    • Background jobs: stop schedulers or cancel running tasks
  • Skip cleanup only when:
    • Tests run in isolated transactions that auto-rollback
    • Cleanup endpoint doesn't exist yet
    • Test explicitly validates failure scenarios where cleanup isn't possible

Example:

func Test_BackupLifecycle_CreateAndDelete(t *testing.T) {
    router := createTestRouter()
    workspace := workspaces_testing.CreateTestWorkspace("Test", owner)

    // Create backup config
    config := createBackupConfig(t, router, workspace.ID, owner.Token)

    // Cleanup at end of test
    defer deleteBackupConfig(t, router, workspace.ID, config.ID, owner.Token)

    // Test operations...
    triggerBackup(t, router, workspace.ID, config.ID, owner.Token)

    // Verify backup was created
    backups := getBackups(t, router, workspace.ID, owner.Token)
    assert.NotEmpty(t, backups)
}

Cloud testing

If you are testing cloud, set isCloud = true before test run and defer isCloud = false after test run. Example helper function:

func enableCloud(t *testing.T) {
	t.Helper()
	config.GetEnv().IsCloud = true
	t.Cleanup(func() {
		config.GetEnv().IsCloud = false
	})
}

Testing utilities structure

Create testing.go or testing/testing.go files with common utilities:

package projects_testing

// CreateTestRouter creates unified router for all controllers
func CreateTestRouter(controllers ...ControllerInterface) *gin.Engine {
    gin.SetMode(gin.TestMode)
    router := gin.New()
    v1 := router.Group("/api/v1")
    protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))

    for _, controller := range controllers {
        if routerGroup, ok := protected.(*gin.RouterGroup); ok {
            controller.RegisterRoutes(routerGroup)
        }
    }
    return router
}

// CreateTestProjectViaAPI creates project through HTTP API
func CreateTestProjectViaAPI(name string, owner *users_dto.SignInResponseDTO, router *gin.Engine) (*projects_models.Project, string) {
    request := projects_dto.CreateProjectRequestDTO{Name: name}
    w := MakeAPIRequest(router, "POST", "/api/v1/projects", "Bearer "+owner.Token, request)
    // Handle response...
    return project, owner.Token
}

// AddMemberToProject adds member via API call
func AddMemberToProject(project *projects_models.Project, member *users_dto.SignInResponseDTO, role users_enums.ProjectRole, ownerToken string, router *gin.Engine) {
    // Implementation...
}

Controller test examples

Permission-based testing:

func Test_CreateApiKey_WhenUserIsProjectOwner_ApiKeyCreated(t *testing.T) {
    router := CreateApiKeyTestRouter(GetProjectController(), GetMembershipController())
    owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
    project, _ := projects_testing.CreateTestProjectViaAPI("Test Project", owner, router)

    request := CreateApiKeyRequestDTO{Name: "Test API Key"}
    var response ApiKey
    test_utils.MakePostRequestAndUnmarshal(t, router, "/api/v1/projects/api-keys/"+project.ID.String(), "Bearer "+owner.Token, request, http.StatusOK, &response)

    assert.Equal(t, "Test API Key", response.Name)
    assert.NotEmpty(t, response.Token)
}

Cross-project security testing:

func Test_UpdateApiKey_WithApiKeyFromDifferentProject_ReturnsBadRequest(t *testing.T) {
    router := CreateApiKeyTestRouter(GetProjectController(), GetMembershipController())
    owner1 := users_testing.CreateTestUser(users_enums.UserRoleMember)
    owner2 := users_testing.CreateTestUser(users_enums.UserRoleMember)
    project1, _ := projects_testing.CreateTestProjectViaAPI("Project 1", owner1, router)
    project2, _ := projects_testing.CreateTestProjectViaAPI("Project 2", owner2, router)

    apiKey := CreateTestApiKey("Cross Project Key", project1.ID, owner1.Token, router)

    // Try to update via different project endpoint
    request := UpdateApiKeyRequestDTO{Name: &"Hacked Key"}
    resp := test_utils.MakePutRequest(t, router, "/api/v1/projects/api-keys/"+project2.ID.String()+"/"+apiKey.ID.String(), "Bearer "+owner2.Token, request, http.StatusBadRequest)

    assert.Contains(t, string(resp.Body), "API key does not belong to this project")
}

E2E lifecycle testing:

func Test_ProjectLifecycleE2E_CompletesSuccessfully(t *testing.T) {
    router := projects_testing.CreateTestRouter(GetProjectController(), GetMembershipController())

    // 1. Create project
    owner := users_testing.CreateTestUser(users_enums.UserRoleMember)
    project := projects_testing.CreateTestProject("E2E Project", owner, router)

    // 2. Add member
    member := users_testing.CreateTestUser(users_enums.UserRoleMember)
    projects_testing.AddMemberToProject(project, member, users_enums.ProjectRoleMember, owner.Token, router)

    // 3. Promote to admin
    projects_testing.ChangeMemberRole(project, member.UserID, users_enums.ProjectRoleAdmin, owner.Token, router)

    // 4. Transfer ownership
    projects_testing.TransferProjectOwnership(project, member.UserID, owner.Token, router)

    // 5. Verify new owner can manage project
    finalProject := projects_testing.GetProject(project.ID, member.Token, router)
    assert.Equal(t, project.ID, finalProject.ID)
}

Time handling

Always use time.Now().UTC() instead of time.Now()

This ensures consistent timezone handling across the application.


Logging

We use log/slog for structured logging. Follow these conventions to keep logs consistent, searchable, and useful for debugging.

Scoped loggers for tracing

Attach IDs via logger.With(...) as early as possible so every downstream log line carries them automatically. Common IDs: database_id, subscription_id, backup_id, storage_id, user_id.

func (s *BillingService) CreateSubscription(logger *slog.Logger, user *users_models.User, databaseID uuid.UUID, storageGB int) {
    logger = logger.With("database_id", databaseID)

    // all subsequent logger calls automatically include database_id
    logger.Debug(fmt.Sprintf("creating subscription for storage %d GB", storageGB))
}

For background services, create scoped loggers with task_name for each subtask in Run():

func (c *BackupCleaner) Run(ctx context.Context) {
    retentionLog := c.logger.With("task_name", "clean_by_retention_policy")
    exceededLog := c.logger.With("task_name", "clean_exceeded_backups")

    // pass scoped logger to each method
    c.cleanByRetentionPolicy(retentionLog)
    c.cleanExceededBackups(exceededLog)
}

Within loops, scope further:

for _, backupConfig := range enabledBackupConfigs {
    dbLog := logger.With("database_id", backupConfig.DatabaseID, "policy", backupConfig.RetentionPolicyType)
    // ...
}

Values in message, IDs as kv pairs

Values and statuses (sizes, counts, status transitions) go into the message via fmt.Sprintf:

logger.Info(fmt.Sprintf("subscription renewed: %s -> %s, %d GB", oldStatus, newStatus, sub.StorageGB))
logger.Info(
    fmt.Sprintf("deleted exceeded backup: backup size is %.1f MB, total size is %.1f MB, limit is %d MB",
        backup.BackupSizeMb, backupsTotalSizeMB, limitPerDbMB),
    "backup_id", backup.ID,
)

IDs stay as structured kv pairs — never inline them into the message string. This keeps them searchable in log aggregation tools:

// good
logger.Info("deleted old backup", "backup_id", backup.ID)

// bad — ID buried in message, not searchable
logger.Info(fmt.Sprintf("deleted old backup %s", backup.ID))

error is always a kv pair, never inlined into the message:

// good
logger.Error("failed to save subscription", "error", err)

// bad
logger.Error(fmt.Sprintf("failed to save subscription: %v", err))

Key naming and message style

  • snake_case for all log keys: database_id, backup_id, task_name, total_size_mb — not camelCase
  • Lowercase log messages: start with lowercase, no trailing period
// good
logger.Error("failed to create checkout session", "error", err)

// bad
logger.Error("Failed to create checkout session.", "error", err)

Log level usage

  • Debug: routine operations, entering a function, query results count ("getting subscription events", "found 5 invoices")
  • Info: significant state changes, completed actions ("subscription activated", "deleted exceeded backup")
  • Warn: degraded but recoverable situations ("oldest backup is too recent to delete", "requested storage is the same as current")
  • Error: failures that need attention ("failed to save subscription", "failed to delete backup file")

CRUD examples

This is an example of complete CRUD implementation structure:

controller.go

package audit_logs

import (
    "net/http"

    user_models "databasus-backend/internal/features/users/models"

    "github.com/gin-gonic/gin"
)

type AuditLogController struct {
    auditLogService *AuditLogService
}

func (c *AuditLogController) RegisterRoutes(router *gin.RouterGroup) {
    auditRoutes := router.Group("/audit-logs")

    auditRoutes.GET("/global", c.GetGlobalAuditLogs)
    auditRoutes.GET("/users/:userId", c.GetUserAuditLogs)
}

// GetGlobalAuditLogs
// @Summary Get global audit logs (ADMIN only)
// @Description Retrieve all audit logs across the system
// @Tags audit-logs
// @Accept json
// @Produce json
// @Security BearerAuth
// @Param limit query int false "Limit number of results" default(100)
// @Param offset query int false "Offset for pagination" default(0)
// @Success 200 {object} GetAuditLogsResponse
// @Failure 401 {object} map[string]string
// @Failure 403 {object} map[string]string
// @Router /audit-logs/global [get]
func (c *AuditLogController) GetGlobalAuditLogs(ctx *gin.Context) {
    user, isOk := ctx.MustGet("user").(*user_models.User)
    if !isOk {
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Invalid user type in context"})
        return
    }

    request := &GetAuditLogsRequest{}
    if err := ctx.ShouldBindQuery(request); err != nil {
        ctx.JSON(http.StatusBadRequest, gin.H{"error": "Invalid query parameters"})
        return
    }

    response, err := c.auditLogService.GetGlobalAuditLogs(user, request)
    if err != nil {
        if err.Error() == "only administrators can view global audit logs" {
            ctx.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
            return
        }
        ctx.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to retrieve audit logs"})
        return
    }

    ctx.JSON(http.StatusOK, response)
}

// GetUserAuditLogs follows the same pattern...

controller_test.go

package audit_logs

func Test_GetGlobalAuditLogs_AdminSucceedsAndMemberGetsForbidden(t *testing.T) {
    adminUser := users_testing.CreateTestUser(user_enums.UserRoleAdmin)
    memberUser := users_testing.CreateTestUser(user_enums.UserRoleMember)
    router := createRouter()
    service := GetAuditLogService()

    createAuditLog(service, "Test log with user", &adminUser.UserID, nil)
    createAuditLog(service, "Test log standalone", nil, nil)

    // Test ADMIN can access global logs
    var response GetAuditLogsResponse
    test_utils.MakeGetRequestAndUnmarshal(t, router,
        "/api/v1/audit-logs/global?limit=10", "Bearer "+adminUser.Token, http.StatusOK, &response)

    assert.GreaterOrEqual(t, len(response.AuditLogs), 2)
    messages := extractMessages(response.AuditLogs)
    assert.Contains(t, messages, "Test log with user")

    // Test MEMBER cannot access global logs
    resp := test_utils.MakeGetRequest(t, router, "/api/v1/audit-logs/global",
        "Bearer "+memberUser.Token, http.StatusForbidden)
    assert.Contains(t, string(resp.Body), "only administrators can view global audit logs")
}

func createRouter() *gin.Engine {
    gin.SetMode(gin.TestMode)
    router := gin.New()
    SetupDependencies()

    v1 := router.Group("/api/v1")
    protected := v1.Group("").Use(users_middleware.AuthMiddleware(users_services.GetUserService()))
    GetAuditLogController().RegisterRoutes(protected.(*gin.RouterGroup))

    return router
}

di.go

package audit_logs

import (
    users_services "databasus-backend/internal/features/users/services"
    "databasus-backend/internal/util/logger"
)

var auditLogRepository = &AuditLogRepository{}
var auditLogService = &AuditLogService{
    auditLogRepository,
    logger.GetLogger(),
}
var auditLogController = &AuditLogController{auditLogService}

func GetAuditLogService() *AuditLogService {
    return auditLogService
}

func GetAuditLogController() *AuditLogController {
    return auditLogController
}

func SetupDependencies() {
    users_services.GetUserService().SetAuditLogWriter(auditLogService)
    users_services.GetSettingsService().SetAuditLogWriter(auditLogService)
    users_services.GetManagementService().SetAuditLogWriter(auditLogService)
}

dto.go

package audit_logs

import "time"

type GetAuditLogsRequest struct {
    Limit      int        `form:"limit"      json:"limit"`
    Offset     int        `form:"offset"     json:"offset"`
    BeforeDate *time.Time `form:"beforeDate" json:"beforeDate"`
}

type GetAuditLogsResponse struct {
    AuditLogs []*AuditLog `json:"auditLogs"`
    Total     int64       `json:"total"`
    Limit     int         `json:"limit"`
    Offset    int         `json:"offset"`
}

model.go

package audit_logs

import (
    "time"

    "github.com/google/uuid"
)

type AuditLog struct {
    ID        uuid.UUID  `json:"id"        gorm:"column:id"`
    UserID    *uuid.UUID `json:"userId"    gorm:"column:user_id"`
    ProjectID *uuid.UUID `json:"projectId" gorm:"column:project_id"`
    Message   string     `json:"message"   gorm:"column:message"`
    CreatedAt time.Time  `json:"createdAt" gorm:"column:created_at"`
}

func (AuditLog) TableName() string {
    return "audit_logs"
}

repository.go

package audit_logs

import (
    "databasus-backend/internal/storage"
    "time"

    "github.com/google/uuid"
)

type AuditLogRepository struct{}

func (r *AuditLogRepository) Create(auditLog *AuditLog) error {
    if auditLog.ID == uuid.Nil {
        auditLog.ID = uuid.New()
    }

    return storage.GetDb().Create(auditLog).Error
}

func (r *AuditLogRepository) GetGlobal(limit, offset int, beforeDate *time.Time) ([]*AuditLog, error) {
    var auditLogs []*AuditLog

    query := storage.GetDb().Order("created_at DESC")

    if beforeDate != nil {
        query = query.Where("created_at < ?", *beforeDate)
    }

    err := query.
        Limit(limit).
        Offset(offset).
        Find(&auditLogs).Error

    return auditLogs, err
}

// GetByUser, GetByProject, CountGlobal follow the same pattern...

service.go

package audit_logs

import (
    "errors"
    "log/slog"
    "time"

    user_enums "databasus-backend/internal/features/users/enums"
    user_models "databasus-backend/internal/features/users/models"

    "github.com/google/uuid"
)

type AuditLogService struct {
    auditLogRepository *AuditLogRepository
    logger             *slog.Logger
}

func (s *AuditLogService) WriteAuditLog(message string, userID *uuid.UUID, projectID *uuid.UUID) {
    auditLog := &AuditLog{
        UserID:    userID,
        ProjectID: projectID,
        Message:   message,
        CreatedAt: time.Now().UTC(),
    }

    if err := s.auditLogRepository.Create(auditLog); err != nil {
        s.logger.Error("failed to create audit log", "error", err)
    }
}

func (s *AuditLogService) GetGlobalAuditLogs(
    user *user_models.User,
    request *GetAuditLogsRequest,
) (*GetAuditLogsResponse, error) {
    if user.Role != user_enums.UserRoleAdmin {
        return nil, errors.New("only administrators can view global audit logs")
    }

    limit := request.Limit
    if limit <= 0 || limit > 1000 {
        limit = 100
    }

    offset := max(request.Offset, 0)

    auditLogs, err := s.auditLogRepository.GetGlobal(limit, offset, request.BeforeDate)
    if err != nil {
        return nil, err
    }

    total, err := s.auditLogRepository.CountGlobal(request.BeforeDate)
    if err != nil {
        return nil, err
    }

    return &GetAuditLogsResponse{
        AuditLogs: auditLogs,
        Total:     total,
        Limit:     limit,
        Offset:    offset,
    }, nil
}

// GetUserAuditLogs, GetProjectAuditLogs follow the same pattern...

service_test.go

package audit_logs

func Test_AuditLogs_ProjectSpecificLogs(t *testing.T) {
    service := GetAuditLogService()
    user1 := users_testing.CreateTestUser(user_enums.UserRoleMember)
    project1ID := uuid.New()

    createAuditLog(service, "Test project1 log first", &user1.UserID, &project1ID)
    createAuditLog(service, "Test project1 log second", &user1.UserID, &project1ID)

    request := &GetAuditLogsRequest{Limit: 10, Offset: 0}

    project1Response, err := service.GetProjectAuditLogs(project1ID, request)
    assert.NoError(t, err)
    assert.Equal(t, 2, len(project1Response.AuditLogs))

    messages := extractMessages(project1Response.AuditLogs)
    assert.Contains(t, messages, "Test project1 log first")
    assert.Contains(t, messages, "Test project1 log second")
}

func createAuditLog(service *AuditLogService, message string, userID, projectID *uuid.UUID) {
    service.WriteAuditLog(message, userID, projectID)
}

func extractMessages(logs []*AuditLog) []string {
    messages := make([]string, len(logs))
    for i, log := range logs {
        messages[i] = log.Message
    }
    return messages
}

Modern Go

Prefer modern Go stdlib idioms over manual equivalents. Use these patterns consistently.

slices package — avoid manual loops

slices.Contains(items, x)                                      // instead of manual loop
slices.Index(items, x)                                         // returns index or -1
slices.IndexFunc(items, func(item T) bool { return item.ID == id })
slices.SortFunc(items, func(a, b T) int { return cmp.Compare(a.X, b.X) })
slices.Sort(items)                                             // for ordered types
slices.Max(items) / slices.Min(items)                         // instead of manual loop
slices.Reverse(items)                                          // in-place
slices.Compact(items)                                          // remove consecutive duplicates
slices.Clone(s)                                                // shallow copy
slices.Clip(s)                                                 // trim unused capacity

any instead of interface{}

// good
func process(value any) {}

// bad
func process(value interface{}) {}

sync.OnceFunc / sync.OnceValue

// instead of sync.Once + wrapper
f := sync.OnceFunc(func() { initialize() })

// compute-once getter
getValue := sync.OnceValue(func() int { return expensiveComputation() })

context helpers

stop := context.AfterFunc(ctx, cleanup)                                  // run cleanup on cancellation
ctx, cancel := context.WithTimeoutCause(parent, d, ErrTimeout)           // timeout with cause
ctx, cancel := context.WithDeadlineCause(parent, deadline, ErrDeadline)  // deadline with cause

Range over integer

// good
for i := range len(items) { ... }

// bad
for i := 0; i < len(items); i++ { ... }

t.Context() in tests

Always use t.Context() — it cancels automatically when the test ends.

// good
func TestFoo(t *testing.T) {
    ctx := t.Context()
    result := doSomething(ctx)
}

// bad
func TestFoo(t *testing.T) {
    ctx, cancel := context.WithCancel(context.Background())
    defer cancel()
    result := doSomething(ctx)
}

omitzero instead of omitempty

Use omitzero for time.Duration, time.Time, structs, slices, and maps — omitempty does not work correctly for these types.

// good
type Config struct {
    Timeout   time.Duration `json:"timeout,omitzero"`
    CreatedAt time.Time     `json:"createdAt,omitzero"`
}

// bad
type Config struct {
    Timeout   time.Duration `json:"timeout,omitempty"` // broken for Duration!
    CreatedAt time.Time     `json:"createdAt,omitempty"`
}

wg.Go() instead of wg.Add(1) + goroutine

// good
var wg sync.WaitGroup
for _, item := range items {
    wg.Go(func() { process(item) })
}
wg.Wait()

// bad
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    go func() {
        defer wg.Done()
        process(item)
    }()
}
wg.Wait()

new(val) for pointer literals

new() accepts expressions since Go 1.26 — avoids the temporary-variable pattern.

// good
cfg := Config{
    Timeout: new(30),    // *int
    Debug:   new(true),  // *bool
}

// bad
timeout := 30
debug := true
cfg := Config{Timeout: &timeout, Debug: &debug}

Frontend guidelines

React component structure

Write React components with the following structure:

interface Props {
   someValue: SomeValue;
}

const someHelperFunction = () => {
    ...
}

export const ReactComponent = ({ someValue }: Props): JSX.Element => {
    // First put states
    const [someState, setSomeState] = useState<...>(...)

    // Then place functions
    const loadSomeData = async () => {
        ...
    }

    // Then hooks
    useEffect(() => {
       loadSomeData();
    });

    // Then calculated values
    const calculatedValue = someValue.calculate();

    return <div> ... </div>
}

Structure order:

  1. Props interface - Define component props
  2. Helper functions (outside component) - Pure utility functions
  3. Component declaration
    • States - useState declarations
    • Functions - Event handlers and async operations
    • Hooks - useEffect, useMemo, useCallback, etc.
    • Calculated values - Derived data from props/state
    • Return - JSX markup

Clipboard operations

Always use ClipboardHelper (shared/lib/ClipboardHelper.ts) for clipboard operations — never call navigator.clipboard directly.

  • Copy: ClipboardHelper.copyToClipboard(text) — uses navigator.clipboard with execCommand('copy') fallback for non-secure contexts (HTTP).
  • Paste: Check ClipboardHelper.isClipboardApiAvailable() first. If available, use ClipboardHelper.readFromClipboard(). If not, show ClipboardPasteModalComponent (shared/ui) which lets the user paste manually via a text input modal.

Summary

This document consolidates all project rules and guidelines. These standards should be followed consistently across all code in the Postgresus project to maintain code quality, readability, and maintainability.