44 KiB
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:
-
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
-
Propose appropriate solutions:
- For complex tasks: 2–3 viable approaches (including a simpler baseline)
- Recommend one with clear tradeoffs
- Consider: complexity, maintainability, performance, future extensibility
-
Identify risks proactively:
- Edge cases and boundary conditions
- Security/privacy pitfalls
- Performance risks and scalability concerns
- Operational concerns (deployment, observability, rollback, monitoring)
-
Handle ambiguity:
- If requirements are ambiguous, make a reasonable default and proceed
- Clearly label your assumptions
- Document what would change under alternative assumptions
-
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
-
Self-review before finalizing:
- Ask: "What could go wrong?"
- Patch the answer accordingly
- Verify edge cases are handled
-
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
returnstatement - 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
- No obvious comments - Don't state what the code already clearly shows
- Functions and variables should have meaningful names - Code should be self-documenting
- 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:
-
When we write controller:
- We combine all routes to single controller
- Names them as
.WhatWeDo(not "handlers") concept
-
We use gin and
*gin.Contextfor all routesExample:
func (c *TasksController) GetAvailableTasks(ctx *gin.Context) ... -
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.Once 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"
"sync/atomic"
"databasus-backend/internal/util/logger"
)
var (
setupOnce sync.Once
isSetup atomic.Bool
)
func SetupDependencies() {
wasAlreadySetup := isSetup.Load()
setupOnce.Do(func() {
// Initialize dependencies here
someService.SetDependency(otherService)
anotherService.AddListener(listener)
isSetup.Store(true)
})
if wasAlreadySetup {
logger.GetLogger().Warn("SetupDependencies called multiple times, ignoring subsequent call")
}
}
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 safe, only log warning
- No panics: Does not break tests or production code on multiple calls
Key Points:
- Check
isSetup.Load()before callingDo()to detect previous executions - Set
isSetup.Store(true)inside theDo()closure after setup completes - Log warning if already setup (helps identify unnecessary duplicate calls)
- All setup logic must be inside the
Do()closure
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 ...
runOnce sync.Once
hasRun atomic.Bool
}
func (s *BackgroundService) Run(ctx context.Context) {
wasAlreadyRun := s.hasRun.Load()
s.runOnce.Do(func() {
s.hasRun.Store(true)
// Existing infinite loop logic
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.doWork()
}
}
})
if wasAlreadyRun {
panic(fmt.Sprintf("%T.Run() called multiple times", s))
}
}
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:
- Check
hasRun.Load()before callingDo()to detect previous executions - Set
hasRun.Store(true)inside theDo()closure before starting work - Always panic if already run (never just log warning)
- All run logic must be inside the
Do()closure - 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_WhatWeExpectTest_WhatWeDo_WhichConditions_WhatWeExpect
Examples from real codebase:
Test_CreateApiKey_WhenUserIsProjectOwner_ApiKeyCreatedTest_UpdateProject_WhenUserIsProjectAdmin_ProjectUpdatedTest_DeleteApiKey_WhenUserIsProjectMember_ReturnsForbiddenTest_GetProjectAuditLogs_WithDifferentUserRoles_EnforcesPermissionsCorrectlyTest_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.goorservice_test.go, notintegration_test.go
Extract common logic to testing utilities:
- Create
testing.goortesting/testing.gofiles 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
deferstatements 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
}
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:
- Props interface - Define component props
- Helper functions (outside component) - Pure utility functions
- Component declaration
- States -
useStatedeclarations - Functions - Event handlers and async operations
- Hooks -
useEffect,useMemo,useCallback, etc. - Calculated values - Derived data from props/state
- Return - JSX markup
- States -
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.