You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1030 lines
28 KiB
Markdown

# Know Foolery - Development Guidelines & Coding Standards
## Overview
This document establishes coding standards, development practices, and guidelines for the Know Foolery project to ensure consistency, maintainability, and quality across the codebase.
## Code Quality Standards
### General Code Quality Orientations
- **Type Safety**: TypeScript for frontend, Go's type system for backend
- **Code Review**: Mandatory peer review for all changes
- **Static Analysis**: Automated code quality checks
- **Documentation**: Comprehensive API and component documentation
### Linting and Formatting
```yaml
# .golangci.yml
run:
timeout: 5m
tests: true
linters:
enable:
- gofmt
- goimports
- govet
- errcheck
- staticcheck
- unused
- gosimple
- structcheck
- varcheck
- ineffassign
- deadcode
- typecheck
- gosec
- misspell
- lll
- unconvert
- dupl
- goconst
- gocyclo
linters-settings:
lll:
line-length: 120
gocyclo:
min-complexity: 15
dupl:
threshold: 150
issues:
exclude-rules:
- path: _test\.go
linters:
- gocyclo
- errcheck
- dupl
- gosec
```
```json
// .eslintrc.json
{
"extends": [
"plugin:solid/typescript",
"@typescript-eslint/recommended",
"prettier"
],
"parser": "@typescript-eslint/parser",
"plugins": ["@typescript-eslint", "solid"],
"rules": {
"@typescript-eslint/explicit-function-return-type": "error",
"@typescript-eslint/no-unused-vars": "error",
"@typescript-eslint/prefer-const": "error",
"solid/reactivity": "warn",
"prefer-const": "error",
"no-var": "error",
"object-shorthand": "error",
"prefer-template": "error"
}
}
```
### Naming Conventions
#### Go Code
```go
// Files: snake_case
user_service.go
jwt_middleware.go
database_client.go
// Packages: lowercase, single word when possible
package auth
package database
package observability
// Exported types: PascalCase
type GameService struct {}
type AuthClaims struct {}
type DatabaseConfig struct {}
// Unexported types: camelCase
type userRepository struct {}
type tokenValidator struct {}
// Functions: PascalCase (exported), camelCase (unexported)
func NewGameService() *GameService {}
func (s *GameService) StartGame() error {}
func validateToken() error {}
// Constants: PascalCase or SCREAMING_SNAKE_CASE for groups
const DefaultTimeout = 30 * time.Second
const (
StatusActive = "active"
StatusInactive = "inactive"
)
// Interfaces: noun or adjective + "er" suffix
type TokenValidator interface {}
type UserRepository interface {}
type ScoreCalculator interface {}
```
#### TypeScript Code
```typescript
// Files: kebab-case
game-card.tsx
user-service.ts
auth-store.ts
// Components: PascalCase
export const GameCard: Component<GameCardProps> = () => {}
export const LeaderboardList: Component = () => {}
// Composables/stores: camelCase with "create"/"use" prefix
export const createGameSessionStore = () => {}
export const useAuthentication = () => {}
// Functions: camelCase
export const calculateScore = () => {}
export const validateAnswer = () => {}
// Types/Interfaces: PascalCase
interface GameSessionProps {}
type AuthenticationState = 'loading' | 'authenticated' | 'unauthenticated'
// Constants: SCREAMING_SNAKE_CASE
export const MAX_ATTEMPTS = 3
export const SESSION_TIMEOUT = 30 * 60 * 1000 // 30 minutes
```
## Go Development Standards
### Fiber Framework Patterns
#### Service Structure
```go
// Service initialization with Fiber
package main
import (
"log"
"github.com/gofiber/fiber/v3"
"github.com/gofiber/fiber/v3/middleware/cors"
"github.com/gofiber/fiber/v3/middleware/logger"
"github.com/gofiber/fiber/v3/middleware/recover"
)
func main() {
// Create Fiber app with configuration
app := fiber.New(fiber.Config{
AppName: "Know Foolery Game Service",
ServerHeader: "Fiber",
ErrorHandler: customErrorHandler,
ReadTimeout: time.Second * 10,
WriteTimeout: time.Second * 10,
IdleTimeout: time.Second * 120,
})
// Global middleware
app.Use(logger.New())
app.Use(recover.New())
app.Use(cors.New(cors.Config{
AllowOrigins: "http://localhost:3000, https://app.knowfoolery.com",
AllowMethods: "GET,POST,PUT,DELETE,OPTIONS",
AllowHeaders: "Origin, Content-Type, Authorization",
}))
// Health check endpoint
app.Get("/health", healthCheck)
// API routes with versioning
api := app.Group("/api/v1")
setupGameRoutes(api)
// Start server
log.Fatal(app.Listen(":8080"))
}
func customErrorHandler(c *fiber.Ctx, err error) error {
code := fiber.StatusInternalServerError
message := "Internal Server Error"
if e, ok := err.(*fiber.Error); ok {
code = e.Code
message = e.Message
}
return c.Status(code).JSON(fiber.Map{
"error": true,
"message": message,
"code": code,
})
}
```
#### Handler Patterns
```go
// Handler structure and patterns
package handlers
import (
"github.com/gofiber/fiber/v3"
"knowfoolery/internal/services"
)
type GameHandler struct {
gameService *services.GameService
validator *validator.Validate
}
func NewGameHandler(gameService *services.GameService) *GameHandler {
return &GameHandler{
gameService: gameService,
validator: validator.New(),
}
}
// HTTP handler with proper error handling
func (h *GameHandler) StartGame(c *fiber.Ctx) error {
var req StartGameRequest
// Parse and validate request
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid request body",
})
}
if err := h.validator.Struct(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Validation failed",
"details": err.Error(),
})
}
// Extract user context from JWT middleware
userID := c.Locals("user_id").(string)
userRole := c.Locals("user_role").(string)
// Business logic
gameSession, err := h.gameService.StartGame(c.Context(), userID, req.PlayerName)
if err != nil {
return handleServiceError(c, err)
}
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"success": true,
"data": gameSession,
})
}
// Centralized error handling
func handleServiceError(c *fiber.Ctx, err error) error {
switch {
case errors.Is(err, services.ErrInvalidInput):
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{
"error": true,
"message": "Invalid input",
"details": err.Error(),
})
case errors.Is(err, services.ErrNotFound):
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{
"error": true,
"message": "Resource not found",
})
case errors.Is(err, services.ErrUnauthorized):
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Unauthorized access",
})
default:
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": true,
"message": "Internal server error",
})
}
}
// Request/Response structures with validation
type StartGameRequest struct {
PlayerName string `json:"player_name" validate:"required,min=2,max=50,alphanum_space"`
}
type StartGameResponse struct {
SessionID string `json:"session_id"`
PlayerName string `json:"player_name"`
StartTime time.Time `json:"start_time"`
TimeRemaining int `json:"time_remaining_seconds"`
}
```
#### Middleware Patterns
```go
// Custom middleware for authentication
package middleware
import (
"github.com/gofiber/fiber/v3"
"knowfoolery/shared/auth"
)
func JWTMiddleware(zitadelRepo auth.ZitadelRepository) fiber.Handler {
return func(c *fiber.Ctx) error {
// Extract token from Authorization header
authHeader := c.Get("Authorization")
if authHeader == "" {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Authorization header required",
})
}
token := strings.TrimPrefix(authHeader, "Bearer ")
if token == authHeader {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid authorization header format",
})
}
// Validate token
claims, err := zitadelRepo.ValidateToken(c.Context(), token)
if err != nil {
return c.Status(fiber.StatusUnauthorized).JSON(fiber.Map{
"error": true,
"message": "Invalid token",
})
}
// Set user context
c.Locals("user_id", claims.Subject)
c.Locals("user_email", claims.Email)
c.Locals("user_name", claims.Name)
c.Locals("user_roles", claims.Roles)
return c.Next()
}
}
// Rate limiting middleware
func RateLimitMiddleware(redisClient *redis.Client) fiber.Handler {
return func(c *fiber.Ctx) error {
userID := c.Locals("user_id")
clientIP := c.IP()
// Per-user rate limiting
if userID != nil {
userKey := fmt.Sprintf("rate_limit:user:%s", userID)
if !allowRequest(redisClient, userKey, 60, time.Minute) {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": true,
"message": "Rate limit exceeded for user",
})
}
}
// Per-IP rate limiting
ipKey := fmt.Sprintf("rate_limit:ip:%s", clientIP)
if !allowRequest(redisClient, ipKey, 100, time.Minute) {
return c.Status(fiber.StatusTooManyRequests).JSON(fiber.Map{
"error": true,
"message": "Rate limit exceeded for IP",
})
}
return c.Next()
}
}
```
### Database Patterns with Ent
#### Schema Definition Standards
```go
// Ent schema with proper field validation and indexes
package schema
import (
"entgo.io/ent"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
"time"
)
type Question struct {
ent.Schema
}
func (Question) Fields() []ent.Field {
return []ent.Field{
field.String("theme").
NotEmpty().
MaxLen(100).
Comment("Question category/theme"),
field.Text("text").
NotEmpty().
MaxLen(1000).
Comment("The actual question text"),
field.String("answer").
NotEmpty().
MaxLen(500).
Comment("Correct answer"),
field.Text("hint").
Optional().
MaxLen(500).
Comment("Optional hint text"),
field.Enum("difficulty").
Values("easy", "medium", "hard").
Default("medium").
Comment("Question difficulty level"),
field.Bool("is_active").
Default(true).
Comment("Whether question is active"),
field.Time("created_at").
Default(time.Now).
Immutable().
Comment("Creation timestamp"),
field.Time("updated_at").
Default(time.Now).
UpdateDefault(time.Now).
Comment("Last update timestamp"),
}
}
func (Question) Indexes() []ent.Index {
return []ent.Index{
index.Fields("theme", "is_active"),
index.Fields("difficulty", "is_active"),
index.Fields("created_at"),
}
}
func (Question) Edges() []ent.Edge {
return []ent.Edge{
edge.To("attempts", QuestionAttempt.Type),
}
}
```
#### Service Layer Patterns
```go
// Service layer with proper error handling
package services
import (
"context"
"errors"
"knowfoolery/internal/models"
"knowfoolery/ent"
)
var (
ErrInvalidInput = errors.New("invalid input")
ErrNotFound = errors.New("resource not found")
ErrUnauthorized = errors.New("unauthorized access")
ErrGameInProgress = errors.New("game already in progress")
ErrSessionExpired = errors.New("session expired")
)
type GameService struct {
client *ent.Client
questionSvc QuestionService
sessionSvc SessionService
validator *validator.Validate
}
func NewGameService(
client *ent.Client,
questionSvc QuestionService,
sessionSvc SessionService,
) *GameService {
return &GameService{
client: client,
questionSvc: questionSvc,
sessionSvc: sessionSvc,
validator: validator.New(),
}
}
func (s *GameService) StartGame(ctx context.Context, userID, playerName string) (*models.GameSession, error) {
// Validate input
if err := s.validatePlayerName(playerName); err != nil {
return nil, fmt.Errorf("%w: %v", ErrInvalidInput, err)
}
// Check for existing active session
activeSession, err := s.client.GameSession.
Query().
Where(gamesession.And(
gamesession.PlayerNameEQ(playerName),
gamesession.StatusEQ(gamesession.StatusActive),
)).
Only(ctx)
if err != nil && !ent.IsNotFound(err) {
return nil, fmt.Errorf("failed to check existing session: %w", err)
}
if activeSession != nil {
return nil, ErrGameInProgress
}
// Start database transaction
tx, err := s.client.Tx(ctx)
if err != nil {
return nil, fmt.Errorf("failed to start transaction: %w", err)
}
defer tx.Rollback()
// Create new game session
session, err := tx.GameSession.
Create().
SetPlayerName(playerName).
SetTotalScore(0).
SetQuestionsAsked(0).
SetQuestionsCorrect(0).
SetHintsUsed(0).
SetStartTime(time.Now()).
SetStatus(gamesession.StatusActive).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed to create session: %w", err)
}
// Get first question
question, err := s.questionSvc.GetRandomQuestion(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get question: %w", err)
}
// Update session with first question
session, err = session.Update().
SetCurrentQuestionID(question.ID).
SetCurrentAttempts(0).
Save(ctx)
if err != nil {
return nil, fmt.Errorf("failed to update session: %w", err)
}
// Commit transaction
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("failed to commit transaction: %w", err)
}
return &models.GameSession{
ID: session.ID,
PlayerName: session.PlayerName,
TotalScore: session.TotalScore,
StartTime: session.StartTime,
Status: string(session.Status),
CurrentQuestion: &models.Question{
ID: question.ID,
Theme: question.Theme,
Text: question.Text,
Hint: question.Hint,
},
TimeRemaining: s.calculateTimeRemaining(session.StartTime),
}, nil
}
func (s *GameService) validatePlayerName(name string) error {
type PlayerNameValidation struct {
Name string `validate:"required,min=2,max=50,alphanum_space"`
}
return s.validator.Struct(&PlayerNameValidation{Name: name})
}
func (s *GameService) calculateTimeRemaining(startTime time.Time) int {
const sessionDuration = 30 * time.Minute
elapsed := time.Since(startTime)
remaining := sessionDuration - elapsed
if remaining < 0 {
return 0
}
return int(remaining.Seconds())
}
```
## TypeScript Development Standards
### SolidJS Component Patterns
#### Component Structure
```typescript
// Component with proper TypeScript types and SolidJS patterns
import type { Component } from 'solid-js'
import { createMemo, createSignal, Show } from 'solid-js'
import {
Box,
Button,
Input,
LinearProgress,
Typography,
Chip,
} from '@suid/material'
// Props interface with proper documentation
export interface GameCardProps {
/** The question to display */
question: string
/** Theme/category of the question */
theme: string
/** Time remaining in seconds */
timeRemaining: number
/** Number of attempts left */
attemptsLeft: number
/** Callback when answer is submitted */
onSubmitAnswer: (answer: string) => Promise<void>
/** Loading state from async operations */
loading?: boolean
}
/**
* GameCard component displays a quiz question with input and controls
*
* @param props - GameCard props
* @returns SolidJS component
*/
export const GameCard: Component<GameCardProps> = (props) => {
const [answer, setAnswer] = createSignal('')
const [isSubmitting, setIsSubmitting] = createSignal(false)
const disabled = createMemo(() => props.loading || isSubmitting())
const progressValue = createMemo(() => ((30 * 60 - props.timeRemaining) / (30 * 60)) * 100)
const handleSubmit = async () => {
const value = answer().trim()
if (!value || disabled()) return
setIsSubmitting(true)
try {
await props.onSubmitAnswer(value)
setAnswer('')
} catch (error) {
console.error('Failed to submit answer:', error)
} finally {
setIsSubmitting(false)
}
}
return (
<Box sx={{ p: 3, border: '1px solid', borderColor: 'divider', borderRadius: 2 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', mb: 1 }}>
<Chip label={props.theme} />
<Typography variant="body2">{props.timeRemaining}s</Typography>
</Box>
<LinearProgress variant="determinate" value={progressValue()} />
<Typography variant="h6" sx={{ mt: 2 }}>{props.question}</Typography>
<Typography variant="body2" sx={{ mt: 1 }}>Attempts left: {props.attemptsLeft}/3</Typography>
<Input
value={answer()}
onInput={(e) => setAnswer(e.currentTarget.value)}
disabled={disabled()}
placeholder="Enter your answer"
/>
<Button onClick={handleSubmit} disabled={disabled() || answer().trim().length === 0}>
Submit Answer
</Button>
<Show when={disabled()}>
<Typography variant="caption">Submitting...</Typography>
</Show>
</Box>
)
}
```
#### Solid Composable Pattern
```typescript
import { createSignal } from 'solid-js'
import { GameSessionService } from '../services/game-session-service'
export const createGameSessionStore = (playerName: () => string) => {
const service = new GameSessionService()
const [isLoading, setIsLoading] = createSignal(false)
const [error, setError] = createSignal<string | null>(null)
const [sessionId, setSessionId] = createSignal<string | null>(null)
const startGame = async () => {
setIsLoading(true)
setError(null)
try {
const session = await service.startGame(playerName())
setSessionId(session.sessionId)
return session
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to start game'
setError(message)
throw err
} finally {
setIsLoading(false)
}
}
return {
isLoading,
error,
sessionId,
startGame,
}
}
```
## Testing Standards
### Go Testing Patterns
```go
// Comprehensive testing with testify and testcontainers
package services_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
"github.com/testcontainers/testcontainers-go/modules/postgres"
"knowfoolery/internal/services"
"knowfoolery/ent/enttest"
)
type GameServiceTestSuite struct {
suite.Suite
ctx context.Context
gameService *services.GameService
client *ent.Client
}
func (suite *GameServiceTestSuite) SetupSuite() {
suite.ctx = context.Background()
// Start PostgreSQL container for integration tests
postgresContainer, err := postgres.RunContainer(suite.ctx,
testcontainers.WithImage("postgres:15-alpine"),
postgres.WithDatabase("testdb"),
postgres.WithUsername("testuser"),
postgres.WithPassword("testpass"),
)
require.NoError(suite.T(), err)
connStr, err := postgresContainer.ConnectionString(suite.ctx)
require.NoError(suite.T(), err)
// Create Ent client with test database
suite.client = enttest.Open(suite.T(), "postgres", connStr)
// Initialize service
suite.gameService = services.NewGameService(
suite.client,
services.NewQuestionService(suite.client),
services.NewSessionService(suite.client),
)
}
func (suite *GameServiceTestSuite) TearDownSuite() {
suite.client.Close()
}
func (suite *GameServiceTestSuite) SetupTest() {
// Clean database before each test
suite.client.GameSession.Delete().ExecX(suite.ctx)
suite.client.Question.Delete().ExecX(suite.ctx)
// Seed test data
suite.seedTestQuestions()
}
func (suite *GameServiceTestSuite) TestStartGame_Success() {
// Arrange
playerName := "TestPlayer"
// Act
session, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
// Assert
require.NoError(suite.T(), err)
assert.NotEmpty(suite.T(), session.ID)
assert.Equal(suite.T(), playerName, session.PlayerName)
assert.Equal(suite.T(), 0, session.TotalScore)
assert.NotNil(suite.T(), session.CurrentQuestion)
assert.True(suite.T(), session.TimeRemaining > 0)
}
func (suite *GameServiceTestSuite) TestStartGame_InvalidPlayerName() {
// Test cases for validation
testCases := []struct {
name string
playerName string
expectedErr string
}{
{"empty name", "", "required"},
{"too short", "a", "min"},
{"too long", strings.Repeat("a", 51), "max"},
{"invalid chars", "player@123", "alphanum_space"},
}
for _, tc := range testCases {
suite.T().Run(tc.name, func(t *testing.T) {
_, err := suite.gameService.StartGame(suite.ctx, "user123", tc.playerName)
require.Error(t, err)
assert.Contains(t, err.Error(), tc.expectedErr)
})
}
}
func (suite *GameServiceTestSuite) TestStartGame_ExistingActiveSession() {
// Arrange
playerName := "TestPlayer"
// Start first game
_, err := suite.gameService.StartGame(suite.ctx, "user123", playerName)
require.NoError(suite.T(), err)
// Act - try to start second game
_, err = suite.gameService.StartGame(suite.ctx, "user123", playerName)
// Assert
require.Error(suite.T(), err)
assert.Equal(suite.T(), services.ErrGameInProgress, err)
}
func (suite *GameServiceTestSuite) seedTestQuestions() {
questions := []struct {
theme string
text string
answer string
hint string
}{
{"Geography", "What is the capital of France?", "Paris", "City of Light"},
{"History", "Who painted the Mona Lisa?", "Leonardo da Vinci", "Renaissance artist"},
{"Science", "What is the largest planet?", "Jupiter", "Gas giant"},
}
for _, q := range questions {
suite.client.Question.Create().
SetTheme(q.theme).
SetText(q.text).
SetAnswer(q.answer).
SetHint(q.hint).
SetIsActive(true).
ExecX(suite.ctx)
}
}
func TestGameServiceSuite(t *testing.T) {
suite.Run(t, new(GameServiceTestSuite))
}
// Benchmark tests
func BenchmarkGameService_StartGame(b *testing.B) {
// Setup
ctx := context.Background()
client := enttest.Open(b, "sqlite3", "file:ent?mode=memory&cache=shared&_fk=1")
defer client.Close()
gameService := services.NewGameService(client, nil, nil)
// Benchmark
b.ResetTimer()
for i := 0; i < b.N; i++ {
playerName := fmt.Sprintf("Player%d", i)
_, _ = gameService.StartGame(ctx, fmt.Sprintf("user%d", i), playerName)
}
}
```
### TypeScript Testing Patterns
```typescript
// SolidJS component testing with Vitest and Testing Library
import { render, screen, fireEvent } from '@solidjs/testing-library'
import { describe, expect, it, vi } from 'vitest'
import { GameCard, GameCardProps } from '../GameCard'
// Mock props factory
const createMockProps = (overrides: Partial<GameCardProps> = {}): GameCardProps => ({
question: "What is the capital of France?",
theme: "Geography",
timeRemaining: 1800, // 30 minutes
attemptsLeft: 3,
onSubmitAnswer: vi.fn(),
loading: false,
...overrides,
})
describe('GameCard', () => {
describe('Rendering', () => {
it('should render question and theme correctly', () => {
const props = createMockProps()
render(() => <GameCard {...props} />)
expect(screen.getByText(props.question)).toBeInTheDocument()
expect(screen.getByText(props.theme)).toBeInTheDocument()
})
})
describe('User Interactions', () => {
it('should call onSubmitAnswer when submit button is clicked', async () => {
const props = createMockProps()
const mockOnSubmitAnswer = vi.fn().mockResolvedValue(undefined)
props.onSubmitAnswer = mockOnSubmitAnswer
render(() => <GameCard {...props} />)
const input = screen.getByPlaceholderText('Enter your answer')
fireEvent.input(input, { target: { value: 'Paris' } })
const submitButton = screen.getByRole('button', { name: 'Submit Answer' })
fireEvent.click(submitButton)
expect(mockOnSubmitAnswer).toHaveBeenCalledWith('Paris')
})
})
})
```
## Git Workflow
```bash
# Branch naming conventions
feature/add-game-session-management
bugfix/fix-timer-display-issue
hotfix/security-vulnerability-patch
refactor/improve-database-queries
# Commit message format
type(scope): description
# Types: feat, fix, docs, style, refactor, test, chore
# Examples:
feat(game): add session timeout handling
fix(auth): resolve JWT token validation issue
docs(api): update authentication endpoint documentation
test(services): add comprehensive game service tests
```
## Development Commands
### Backend Development
```bash
# Navigate to service directory first
cd backend/services/{service-name}
# Start service in development mode with hot reload
go run cmd/main.go
# Service ports are env-driven via shared bootstrap helpers:
# ADMIN_SERVICE_PORT, GAME_SESSION_PORT, GATEWAY_PORT,
# LEADERBOARD_PORT, QUESTION_BANK_PORT, USER_SERVICE_PORT
# Run tests for a specific service
go test ./... -v
# Run tests with coverage
go test ./... -cover
# Build service binary
go build -o bin/{service-name} cmd/main.go
# Generate Ent schemas (from service root)
go generate ./...
# Lint Go code
golangci-lint run
# Format Go code
go fmt ./...
goimports -w .
```
### Development Environment
```bash
# Start development stack with Docker Compose
cd infrastructure/dev
docker-compose up -d
# This starts:
# - PostgreSQL database (port 5432)
# - Redis cache (port 6379)
# - Zitadel auth server (port 8080)
```
### Frontend Development
Note: This project uses Yarn with `nodeLinker: node-modules`. PnP files like `.pnp.cjs` and `.pnp.loader.mjs` are not used.
Status: `Web` commands are implemented. `Tauri cross-platform` commands are planned and require a future `frontend/apps/cross-platform` workspace.
```bash
# Web application
cd frontend
yarn install
yarn dev # Start web development server (SolidJS)
yarn build # Build all frontend workspaces
yarn test # Run Vitest suites
yarn lint # ESLint
yarn format:check # Prettier check
# Cross-platform packaging with Tauri (desktop/mobile)
cd frontend/apps/cross-platform
yarn tauri dev # Run Tauri app with embedded web UI
yarn tauri build # Build release bundles
```