diff --git a/.env.dev.example b/.env.dev.example index ccde902..62dc3e5 100644 --- a/.env.dev.example +++ b/.env.dev.example @@ -1,21 +1,21 @@ -GO_VERSION=1.26.1 - -APP_ENV=development -HTTP_ADDR=:8080 - -HTTP_READ_HEADER_TIMEOUT=5s -HTTP_READ_TIMEOUT=15s -HTTP_WRITE_TIMEOUT=30s -HTTP_IDLE_TIMEOUT=60s -HTTP_SHUTDOWN_TIMEOUT=10s -HTTP_MAX_BODY_BYTES=1048576 - -CORS_ALLOWED_ORIGINS=http://localhost:3000 - -MIGRATIONS_DIR=migrations - -POSTGRES_HOST=localhost -POSTGRES_PORT=5432 +GO_VERSION=1.26.1 + +APP_ENV=development +HTTP_ADDR=:8080 + +HTTP_READ_HEADER_TIMEOUT=5s +HTTP_READ_TIMEOUT=15s +HTTP_WRITE_TIMEOUT=30s +HTTP_IDLE_TIMEOUT=60s +HTTP_SHUTDOWN_TIMEOUT=10s +HTTP_MAX_BODY_BYTES=1048576 + +CORS_ALLOWED_ORIGINS=http://localhost:3000 + +MIGRATIONS_DIR=migrations + +POSTGRES_HOST=localhost +POSTGRES_PORT=5432 POSTGRES_DB=gamidoc POSTGRES_USER=gamidoc POSTGRES_PASSWORD=gamidoc @@ -31,19 +31,19 @@ JWT_EXPIRES_IN=24h REFRESH_TOKEN_TTL=168h SESSION_TTL=48h - -OBJECT_STORAGE_PROVIDER=local -OBJECT_STORAGE_PUBLIC_BASE_URL=/files/pdfs -OBJECT_STORAGE_LOCAL_ROOT_DIR=.localdata/pdfs -OBJECT_STORAGE_S3_BUCKET= -OBJECT_STORAGE_S3_REGION=auto -OBJECT_STORAGE_S3_ENDPOINT= -OBJECT_STORAGE_S3_ACCESS_KEY_ID= -OBJECT_STORAGE_S3_SECRET_ACCESS_KEY= -OBJECT_STORAGE_S3_USE_PATH_STYLE=false - -MAILER_PROVIDER=noop -MAILER_FROM_EMAIL= + +OBJECT_STORAGE_PROVIDER=local +OBJECT_STORAGE_PUBLIC_BASE_URL=/files/pdfs +OBJECT_STORAGE_LOCAL_ROOT_DIR=.localdata/pdfs +OBJECT_STORAGE_S3_BUCKET= +OBJECT_STORAGE_S3_REGION=auto +OBJECT_STORAGE_S3_ENDPOINT= +OBJECT_STORAGE_S3_ACCESS_KEY_ID= +OBJECT_STORAGE_S3_SECRET_ACCESS_KEY= +OBJECT_STORAGE_S3_USE_PATH_STYLE=false + +MAILER_PROVIDER=noop +MAILER_FROM_EMAIL= MAILER_FROM_NAME=GamiDoc RESEND_API_KEY= RESEND_BASE_URL=https://api.resend.com @@ -52,3 +52,9 @@ PDF_HTML_RENDERER_URL= PDF_HTML_RENDERER_TIMEOUT=30s RECOMMENDATION_RULES_PATH=rule/recommendations.json + +AI_PROVIDER=noop +AI_BASE_URL=https://api.openai.com/v1 +AI_API_KEY= +AI_MODEL= +AI_TIMEOUT=60s diff --git a/.env.prod.example b/.env.prod.example index 6e96816..85feda1 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -50,3 +50,9 @@ PDF_HTML_RENDERER_URL=http://gamidoc-backend-prod-gotenberg:3000/forms/chromium/ PDF_HTML_RENDERER_TIMEOUT=30s RECOMMENDATION_RULES_PATH=/app/rule/recommendations.json + +AI_PROVIDER=noop +AI_BASE_URL=https://api.openai.com/v1 +AI_API_KEY= +AI_MODEL= +AI_TIMEOUT=60s diff --git a/.gitignore b/.gitignore index 41a2323..b58f384 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /api /docs/reference/* + +*.exe diff --git a/config/config.go b/config/config.go index ae37c42..2a75e5c 100644 --- a/config/config.go +++ b/config/config.go @@ -63,6 +63,12 @@ type Config struct { PDFHTMLRendererTimeout time.Duration RecommendationRulesPath string + + AIProvider string + AIBaseURL string + AIAPIKey string + AIModel string + AITimeout time.Duration } func Load() Config { @@ -112,6 +118,11 @@ func Load() Config { PDFHTMLRendererURL: getEnv("PDF_HTML_RENDERER_URL", ""), PDFHTMLRendererTimeout: parseDurationWithFallback(getEnv("PDF_HTML_RENDERER_TIMEOUT", "30s"), 30*time.Second), RecommendationRulesPath: getEnv("RECOMMENDATION_RULES_PATH", "rule/recommendations.json"), + AIProvider: getEnv("AI_PROVIDER", "noop"), + AIBaseURL: getEnv("AI_BASE_URL", "https://api.openai.com/v1"), + AIAPIKey: getEnv("AI_API_KEY", ""), + AIModel: getEnv("AI_MODEL", ""), + AITimeout: parseDurationWithFallback(getEnv("AI_TIMEOUT", "60s"), 60*time.Second), } } @@ -125,6 +136,9 @@ func (c Config) Validate() error { if err := c.ValidateMailer(); err != nil { return err } + if err := c.ValidateAI(); err != nil { + return err + } return nil } @@ -259,6 +273,38 @@ func (c Config) ValidateObjectStorage() error { } } +func (c Config) AIProviderNormalized() string { + value := strings.ToLower(strings.TrimSpace(c.AIProvider)) + switch value { + case "", "noop": + return "noop" + case "openai", "openai-compatible": + return "openai-compatible" + default: + return value + } +} + +func (c Config) ValidateAI() error { + switch c.AIProviderNormalized() { + case "noop": + return nil + case "openai-compatible": + if strings.TrimSpace(c.AIBaseURL) == "" { + return errors.New("ai base url is required") + } + if strings.TrimSpace(c.AIAPIKey) == "" { + return errors.New("ai api key is required") + } + if strings.TrimSpace(c.AIModel) == "" { + return errors.New("ai model is required") + } + return nil + default: + return fmt.Errorf("unsupported ai provider: %s", c.AIProvider) + } +} + func (c Config) ValidateMailer() error { switch c.MailerProviderNormalized() { case "noop": @@ -289,6 +335,7 @@ func (c Config) SafeSummary() map[string]any { "object_storage_provider": c.ObjectStorageProviderNormalized(), "mailer_provider": c.MailerProviderNormalized(), "pdf_html_renderer": strings.TrimSpace(c.PDFHTMLRendererURL) != "", + "ai_provider": c.AIProviderNormalized(), "recommendation_rules": c.RecommendationRulesPath, } } diff --git a/docker-compose.yaml b/docker-compose.yaml index ec972ee..3d1a179 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -22,7 +22,7 @@ services: env_file: - .env.prod volumes: - - gamidoc-backend-prod-pg-data:/var/lib/postgresql/data + - gamidoc-backend-prod-pg-data:/var/lib/postgresql gamidoc-backend-prod-redis: image: redis:8-bookworm diff --git a/docs/api/design.md b/docs/api/design.md new file mode 100644 index 0000000..cf1847d --- /dev/null +++ b/docs/api/design.md @@ -0,0 +1,122 @@ +# Design System + +The Design System component guides a user through the seven sections of the GamiDoc design flow. Routes exist in two mirrored scopes: + +- `/api/v1/sessions/{sessionId}/design/...` for the anonymous flow, no auth, state stored in Redis with the session TTL +- `/api/v1/projects/{projectId}/design/...` for authenticated users, Bearer auth plus ownership check, state stored in Postgres + +Unless stated otherwise, every route in both scopes shares the guard errors below in addition to its own: + +| Scope | Guard errors | +| --- | --- | +| Sessions | `400 INVALID_SESSION_ID`, `404 SESSION_NOT_FOUND`, `500 INTERNAL_SERVER_ERROR` | +| Projects | `401 UNAUTHORIZED`, `400 INVALID_PROJECT_ID`, `404 PROJECT_NOT_FOUND`, `403 FORBIDDEN`, `500 INTERNAL_SERVER_ERROR` | + +## Flow Model + +- Section 1 (Context) is always first. After it, the user picks path `A` (1,2,3,4,5,6,7) or `B` (1,4,5,2,3,6,7). +- On the first pass, sections unlock in path order; a visited section can be re-saved at any time. Sections can also be skipped. +- After a full first pass, navigation is free and the dashboard unlocks (it needs at least one filled section). +- The AI provider is selected with `AI_PROVIDER` (`noop` default, or `openai-compatible` with `AI_BASE_URL`, `AI_API_KEY`, `AI_MODEL`). + +## `GET .../design` + +| Field | Value | +| --- | --- | +| Success | `200` design status object | +| Notes | Returns the full design state: spark, path, cursor, first-pass flag, sections, session-generated reports | + +## `PUT .../design/spark` + +| Field | Value | +| --- | --- | +| Body | `{ "spark": "..." }` | +| Success | `200` `{ "designStatus", "prefillApplied", "prefillFailed" }` | +| Errors | `400 INVALID_INPUT` | +| Notes | A non-empty spark asks the AI provider to prefill empty sections; user-entered content is never overwritten. `prefillFailed` is `true` when the provider errored, the spark itself is still saved | + +## `GET .../design/branch` + +| Field | Value | +| --- | --- | +| Success | `200` `{ "recommendedPath": "A"\|"B", "basis": "spark"\|"default" }` | +| Errors | `502 AI_PROVIDER_ERROR` | +| Notes | Without a spark the default recommendation is path `A` | + +## `POST .../design/path` + +| Field | Value | +| --- | --- | +| Body | `{ "path": "A"\|"B" }` | +| Success | `200` design status object | +| Errors | `400 INVALID_INPUT`, `400 INVALID_PATH`, `400 SECTION_LOCKED`, `409 PATH_ALREADY_CHOSEN` | +| Notes | Allowed only right after the Context section on the first pass | + +## `PUT .../design/section/{sectionNumber}` + +| Field | Value | +| --- | --- | +| Body | `{ "content": {...}, "complete": true\|false, "skip": true\|false }` | +| Success | `200` `{ "sectionNumber", "section", "designStatus" }` | +| Errors | `400 INVALID_INPUT`, `400 INVALID_SECTION_NUMBER`, `400 INVALID_SECTION_DATA`, `400 SECTION_LOCKED`, `400 PATH_NOT_CHOSEN` | +| Notes | `content` is free-form JSON, field order is preserved in reports. `complete` is optional; omitting it keeps the current completion flag. `skip: true` marks the section visited without content | + +## `GET .../design/dashboard` + +| Field | Value | +| --- | --- | +| Success | `200` `{ "overallPercent", "firstPassDone", "path", "sections": [{ "sectionNumber", "name", "description", "status", "percent" }] }` | +| Errors | `403 DASHBOARD_LOCKED` | +| Notes | Section status is `not_started` (no content, even if visited or skipped), `in_progress` (has content, not marked complete), or `complete`; the percentages are 0, 50, 100 | + +## `POST .../design/generate-pdf` + +| Field | Value | +| --- | --- | +| Body | optional; when present it must be valid JSON | +| Success | `200` `{ "standard": { "reportId", "version", "url", "createdAt" }, "enhanced": {...} }` | +| Errors | `400 INVALID_INPUT`, `403 DASHBOARD_LOCKED`, `502 AI_PROVIDER_ERROR` (AI failures), `500 INTERNAL_SERVER_ERROR` (build, storage, or database failures) | +| Notes | One trigger produces both report versions: `standard` mirrors the form content in order, `enhanced` is AI-consolidated prose with cross-section deduplication. Project reports are recorded in `design_reports`; session reports are kept in the session design state | + +## `GET .../design/faq/{sectionNumber}` + +| Field | Value | +| --- | --- | +| Success | `200` `{ "sectionNumber", "faq": [{ "question", "answer" }] }` | +| Errors | `400 INVALID_SECTION_NUMBER` | + +## `POST .../design/ai/rewrite` + +| Field | Value | +| --- | --- | +| Body | `{ "text": "..." }` | +| Success | `200` `{ "text", "previousText" }` | +| Errors | `400 INVALID_INPUT`, `400 EMPTY_TEXT`, `502 AI_PROVIDER_ERROR` | + +## `POST .../design/ai/chat` + +| Field | Value | +| --- | --- | +| Body | `{ "sectionNumber": 1..7, "message": "..." }` | +| Success | `200` `{ "sectionNumber", "reply", "faq" }` | +| Errors | `400 INVALID_INPUT`, `400 INVALID_SECTION_NUMBER`, `502 AI_PROVIDER_ERROR` | + +## `GET /api/v1/projects/{projectId}/design/reports` + +| Field | Value | +| --- | --- | +| Success | `200` `{ "reports": [...], "total" }` | +| Notes | Project scope only; newest first | + +## `POST /api/v1/projects/{projectId}/design/import-session` + +| Field | Value | +| --- | --- | +| Body | `{ "sessionId": "..." }` | +| Success | `200` imported design status object | +| Errors | `400 INVALID_INPUT`, `404 SESSION_NOT_FOUND`, `409 SESSION_DESIGN_EMPTY`, `409 DESIGN_NOT_EMPTY` | +| Notes | Project scope only. Refuses to import an empty session state and refuses to overwrite existing project design content. Session-generated reports are migrated into `design_reports` under fresh ids, keeping their timestamps. The design state lives independently of the session record, so importing still works after the session was converted or deleted, until the state expires with the Redis TTL; `404 SESSION_NOT_FOUND` is returned only when neither the session nor any design state exists | + +## Activity Events + +Design routes are tracked by the activity middleware: `design_section_saved`, `design_path_chosen`, `design_pdf_generated`, `design_imported`; everything else falls back to `api_request`. diff --git a/docs/api/index.md b/docs/api/index.md index d3b680d..907aa44 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -9,6 +9,7 @@ This section documents the current backend API, the auth model, the PDF flow, an - [Projects](projects.md) - [Sessions](sessions.md) - [PDF delivery](pdf.md) +- [Design System](design.md) - [Activity tracking](activity.md) - [Requirement coverage and gaps](coverage.md) diff --git a/gamidoc-backend.exe b/gamidoc-backend.exe deleted file mode 100644 index a92413b..0000000 Binary files a/gamidoc-backend.exe and /dev/null differ diff --git a/internal/activity/domain.go b/internal/activity/domain.go index 6edd105..c6883ca 100644 --- a/internal/activity/domain.go +++ b/internal/activity/domain.go @@ -22,6 +22,10 @@ const ( EventPDFDownloaded = "pdf_downloaded" EventSessionCreated = "session_created" EventSessionConverted = "session_converted" + EventDesignSectionSaved = "design_section_saved" + EventDesignPathChosen = "design_path_chosen" + EventDesignPDFGenerated = "design_pdf_generated" + EventDesignImported = "design_imported" EventFrontendPrefix = "frontend_" ) diff --git a/internal/ai/assistant.go b/internal/ai/assistant.go new file mode 100644 index 0000000..58367a2 --- /dev/null +++ b/internal/ai/assistant.go @@ -0,0 +1,28 @@ +package ai + +import ( + "context" + "encoding/json" + "errors" +) + +var ErrEmptyText = errors.New("empty text") + +type Section struct { + Number int + Name string +} + +type SectionText struct { + Number int + Name string + Text string +} + +type Assistant interface { + Rewrite(ctx context.Context, text string) (string, error) + Chat(ctx context.Context, section Section, message string) (string, error) + RecommendBranch(ctx context.Context, spark string) (string, error) + Prefill(ctx context.Context, spark string, sections []Section) (map[int]json.RawMessage, error) + Enhance(ctx context.Context, sections []SectionText) ([]SectionText, error) +} diff --git a/internal/ai/noop_assistant.go b/internal/ai/noop_assistant.go new file mode 100644 index 0000000..33dd224 --- /dev/null +++ b/internal/ai/noop_assistant.go @@ -0,0 +1,94 @@ +package ai + +import ( + "context" + "encoding/json" + "strings" +) + +type NoopAssistant struct{} + +func NewNoopAssistant() *NoopAssistant { + return &NoopAssistant{} +} + +var experienceTerms = []string{ + "experience", "journey", "timeline", "player", "user", "persona", "story", "narrative", "emotion", "motivation", +} + +var mechanicsTerms = []string{ + "mechanic", "points", "score", "badge", "leaderboard", "reward", "challenge", "level", "technology", "platform", "app", +} + +var sectionGuidance = map[int]string{ + 1: "Describe the project context: the domain, the target audience, and the problem the gamified system should address.", + 2: "Describe the experience over time: how a user first meets the system, what a typical session looks like, and how the experience evolves.", + 3: "Describe the personification and dynamics: personas or player types, and the social or competitive dynamics between them.", + 4: "Describe the gameful core: the central mechanics, rules, and reward structures.", + 5: "Describe the technology: platforms, devices, integrations, and technical constraints.", + 6: "Describe the impacts and benefits: the intended behavioural, learning, or business outcomes.", + 7: "Describe evaluation and feedback: how the system's effect will be measured and how feedback loops reach the users.", +} + +func (a *NoopAssistant) Rewrite(ctx context.Context, text string) (string, error) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "", ErrEmptyText + } + return strings.Join(strings.Fields(trimmed), " "), nil +} + +func (a *NoopAssistant) Chat(ctx context.Context, section Section, message string) (string, error) { + guidance, ok := sectionGuidance[section.Number] + if !ok { + guidance = "Fill in the section with the information most relevant to your project." + } + return guidance, nil +} + +func (a *NoopAssistant) RecommendBranch(ctx context.Context, spark string) (string, error) { + lowered := strings.ToLower(spark) + experience := 0 + mechanics := 0 + for _, term := range experienceTerms { + experience += strings.Count(lowered, term) + } + for _, term := range mechanicsTerms { + mechanics += strings.Count(lowered, term) + } + if mechanics > experience { + return "B", nil + } + return "A", nil +} + +func (a *NoopAssistant) Prefill(ctx context.Context, spark string, sections []Section) (map[int]json.RawMessage, error) { + trimmed := strings.TrimSpace(spark) + if trimmed == "" { + return map[int]json.RawMessage{}, nil + } + draft, err := json.Marshal(map[string]string{"draft": trimmed}) + if err != nil { + return nil, err + } + return map[int]json.RawMessage{1: draft}, nil +} + +func (a *NoopAssistant) Enhance(ctx context.Context, sections []SectionText) ([]SectionText, error) { + seen := map[string]bool{} + result := make([]SectionText, 0, len(sections)) + for _, section := range sections { + var kept []string + for _, line := range strings.Split(section.Text, "\n") { + normalized := strings.Join(strings.Fields(strings.TrimSpace(line)), " ") + if normalized == "" || seen[strings.ToLower(normalized)] { + continue + } + seen[strings.ToLower(normalized)] = true + kept = append(kept, normalized) + } + section.Text = strings.Join(kept, " ") + result = append(result, section) + } + return result, nil +} diff --git a/internal/ai/noop_assistant_test.go b/internal/ai/noop_assistant_test.go new file mode 100644 index 0000000..9395626 --- /dev/null +++ b/internal/ai/noop_assistant_test.go @@ -0,0 +1,96 @@ +package ai + +import ( + "context" + "errors" + "testing" +) + +func TestNoopRewrite(t *testing.T) { + assistant := NewNoopAssistant() + + result, err := assistant.Rewrite(context.Background(), " hello world ") + if err != nil { + t.Fatal(err) + } + if result != "hello world" { + t.Fatalf("unexpected rewrite %q", result) + } + + if _, err := assistant.Rewrite(context.Background(), " "); !errors.Is(err, ErrEmptyText) { + t.Fatalf("expected ErrEmptyText, got %v", err) + } +} + +func TestNoopRecommendBranch(t *testing.T) { + assistant := NewNoopAssistant() + + branch, err := assistant.RecommendBranch(context.Background(), "points, badges and a leaderboard on a mobile app") + if err != nil { + t.Fatal(err) + } + if branch != "B" { + t.Fatalf("expected B, got %s", branch) + } + + branch, err = assistant.RecommendBranch(context.Background(), "the player journey and user experience over time") + if err != nil { + t.Fatal(err) + } + if branch != "A" { + t.Fatalf("expected A, got %s", branch) + } +} + +func TestNoopPrefill(t *testing.T) { + assistant := NewNoopAssistant() + + prefill, err := assistant.Prefill(context.Background(), "a commuting game", nil) + if err != nil { + t.Fatal(err) + } + if len(prefill) != 1 { + t.Fatalf("expected one prefilled section, got %d", len(prefill)) + } + if _, ok := prefill[1]; !ok { + t.Fatal("expected section 1 prefilled") + } + + empty, err := assistant.Prefill(context.Background(), " ", nil) + if err != nil { + t.Fatal(err) + } + if len(empty) != 0 { + t.Fatalf("expected no prefill, got %d", len(empty)) + } +} + +func TestNoopChat(t *testing.T) { + assistant := NewNoopAssistant() + + reply, err := assistant.Chat(context.Background(), Section{Number: 4, Name: "Gameful Core"}, "what goes here?") + if err != nil { + t.Fatal(err) + } + if reply == "" { + t.Fatal("expected non-empty guidance") + } +} + +func TestNoopEnhanceDeduplicatesAcrossSections(t *testing.T) { + assistant := NewNoopAssistant() + + result, err := assistant.Enhance(context.Background(), []SectionText{ + {Number: 1, Name: "Context", Text: "students commute daily\nwalking is healthy"}, + {Number: 6, Name: "Impacts & Benefits", Text: "walking is healthy\nless car traffic"}, + }) + if err != nil { + t.Fatal(err) + } + if len(result) != 2 { + t.Fatalf("expected 2 sections, got %d", len(result)) + } + if result[1].Text != "less car traffic" { + t.Fatalf("expected duplicate line dropped, got %q", result[1].Text) + } +} diff --git a/internal/ai/openai_assistant.go b/internal/ai/openai_assistant.go new file mode 100644 index 0000000..f7f5965 --- /dev/null +++ b/internal/ai/openai_assistant.go @@ -0,0 +1,206 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "strconv" + "strings" +) + +type OpenAIAssistant struct { + baseURL string + apiKey string + model string + client *http.Client +} + +func NewOpenAIAssistant(baseURL string, apiKey string, model string, client *http.Client) *OpenAIAssistant { + if client == nil { + client = http.DefaultClient + } + return &OpenAIAssistant{ + baseURL: strings.TrimRight(baseURL, "/"), + apiKey: apiKey, + model: model, + client: client, + } +} + +type chatMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type chatRequest struct { + Model string `json:"model"` + Messages []chatMessage `json:"messages"` +} + +type chatResponse struct { + Choices []struct { + Message chatMessage `json:"message"` + } `json:"choices"` +} + +func (a *OpenAIAssistant) complete(ctx context.Context, system string, user string) (string, error) { + payload, err := json.Marshal(chatRequest{ + Model: a.model, + Messages: []chatMessage{ + {Role: "system", Content: system}, + {Role: "user", Content: user}, + }, + }) + if err != nil { + return "", err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, a.baseURL+"/chat/completions", bytes.NewReader(payload)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Authorization", "Bearer "+a.apiKey) + + resp, err := a.client.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return "", fmt.Errorf("ai provider returned status %d", resp.StatusCode) + } + + var decoded chatResponse + if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { + return "", err + } + if len(decoded.Choices) == 0 { + return "", fmt.Errorf("ai provider returned no choices") + } + + answer := strings.TrimSpace(decoded.Choices[0].Message.Content) + if answer == "" { + return "", fmt.Errorf("ai provider returned an empty completion") + } + + return answer, nil +} + +func (a *OpenAIAssistant) Rewrite(ctx context.Context, text string) (string, error) { + trimmed := strings.TrimSpace(text) + if trimmed == "" { + return "", ErrEmptyText + } + return a.complete(ctx, + "Rewrite the user's text into grammatically correct, professionally phrased prose. Preserve the meaning. Do not add new claims. Reply with the rewritten text only.", + trimmed, + ) +} + +func (a *OpenAIAssistant) Chat(ctx context.Context, section Section, message string) (string, error) { + system := fmt.Sprintf( + "You are the GamiDoc assistant. The user is filling in the '%s' section of a gamification design document. Give concise, concrete guidance for this section. Clarify domain terminology when asked.", + section.Name, + ) + return a.complete(ctx, system, message) +} + +func (a *OpenAIAssistant) RecommendBranch(ctx context.Context, spark string) (string, error) { + answer, err := a.complete(ctx, + "The user wrote a free-form project idea. Answer with the single letter A if the text mostly concerns user experience, personas, or journeys; answer B if it mostly concerns game mechanics, rewards, or technology. Answer with A or B only.", + spark, + ) + if err != nil { + return "", err + } + words := strings.FieldsFunc(strings.ToUpper(answer), func(r rune) bool { + return r < 'A' || r > 'Z' + }) + for _, word := range words { + if word == "A" || word == "B" { + return word, nil + } + } + return "A", nil +} + +func (a *OpenAIAssistant) Prefill(ctx context.Context, spark string, sections []Section) (map[int]json.RawMessage, error) { + trimmed := strings.TrimSpace(spark) + if trimmed == "" { + return map[int]json.RawMessage{}, nil + } + + var names []string + for _, section := range sections { + names = append(names, fmt.Sprintf("%d: %s", section.Number, section.Name)) + } + system := fmt.Sprintf( + "From the user's project idea, draft initial content for the sections of a gamification design document: %s. Reply with a JSON object whose keys are the section numbers as strings and whose values are objects with a single 'draft' string field. Include only sections the idea gives real material for. Reply with JSON only.", + strings.Join(names, "; "), + ) + + answer, err := a.complete(ctx, system, trimmed) + if err != nil { + return nil, err + } + answer = strings.TrimPrefix(answer, "```json") + answer = strings.Trim(answer, "` \n") + + var decoded map[string]json.RawMessage + if err := json.Unmarshal([]byte(answer), &decoded); err != nil { + return nil, fmt.Errorf("ai provider returned unparseable prefill: %w", err) + } + + result := make(map[int]json.RawMessage, len(decoded)) + for key, value := range decoded { + number, err := strconv.Atoi(key) + if err != nil { + continue + } + result[number] = value + } + return result, nil +} + +func (a *OpenAIAssistant) Enhance(ctx context.Context, sections []SectionText) ([]SectionText, error) { + var filtered []SectionText + var parts []string + for _, section := range sections { + if strings.TrimSpace(section.Text) == "" { + continue + } + filtered = append(filtered, section) + parts = append(parts, fmt.Sprintf("Section %d (%s):\n%s", section.Number, section.Name, section.Text)) + } + if len(filtered) == 0 { + return nil, nil + } + + answer, err := a.complete(ctx, + "Rewrite the raw notes of a gamification design report into naturally readable prose, section by section. Preserve the meaning and facts; do not add new claims. Where content repeats across sections, keep it in the most fitting section and drop the repetitions elsewhere. Reply with a JSON object whose keys are the section numbers as strings and whose values are the prose strings. Reply with JSON only.", + strings.Join(parts, "\n\n"), + ) + if err != nil { + return nil, err + } + answer = strings.TrimPrefix(answer, "```json") + answer = strings.Trim(answer, "` \n") + + var decoded map[string]string + if err := json.Unmarshal([]byte(answer), &decoded); err != nil { + return nil, fmt.Errorf("ai provider returned unparseable enhancement: %w", err) + } + + result := make([]SectionText, 0, len(filtered)) + for _, section := range filtered { + if prose, ok := decoded[strconv.Itoa(section.Number)]; ok && strings.TrimSpace(prose) != "" { + section.Text = strings.TrimSpace(prose) + } + result = append(result, section) + } + return result, nil +} diff --git a/internal/app/app.go b/internal/app/app.go index b8e1196..d3bb0c7 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/gamidoc/backend/internal/activity" "github.com/gamidoc/backend/internal/auth" "github.com/gamidoc/backend/internal/bootstrap" + "github.com/gamidoc/backend/internal/design" apphttp "github.com/gamidoc/backend/internal/http" "github.com/gamidoc/backend/internal/migrate" "github.com/gamidoc/backend/internal/pdf" @@ -134,6 +135,29 @@ func New(cfg config.Config) (*App, error) { } pdfHandler := pdf.NewHandler(pdfService) + assistant, err := bootstrap.NewAssistant(cfg) + if err != nil { + _ = pg.Close() + _ = redisClient.Close() + return nil, err + } + + designReports := design.NewReportService( + assistant, + design.NewReportBuilder(), + store, + postgres.NewDesignReportRepository(pg), + ) + designHandler := design.NewHandler( + design.NewService(), + assistant, + designReports, + sessionRepository, + rediscache.NewDesignRepository(redisClient, cfg.SessionTTL), + projectRepository, + postgres.NewDesignStateRepository(pg), + ) + application := &App{ config: cfg, logger: logger, @@ -152,6 +176,7 @@ func New(cfg config.Config) (*App, error) { AuthHandler: authHandler.Routes(), ProjectHandler: projectHandler, SessionHandler: sessionHandler, + DesignHandler: designHandler, PDFHandler: pdfHandler, PDFBaseURL: cfg.ObjectStoragePublicBaseURL, MaxBodyBytes: cfg.HTTPMaxBodyBytes, @@ -169,6 +194,7 @@ func New(cfg config.Config) (*App, error) { "object_storage_provider", summary["object_storage_provider"], "mailer_provider", summary["mailer_provider"], "pdf_html_renderer", summary["pdf_html_renderer"], + "ai_provider", summary["ai_provider"], "recommendation_rules", summary["recommendation_rules"], ) diff --git a/internal/bootstrap/providers.go b/internal/bootstrap/providers.go index ea2820b..cb31c34 100644 --- a/internal/bootstrap/providers.go +++ b/internal/bootstrap/providers.go @@ -3,8 +3,10 @@ package bootstrap import ( "context" "fmt" + "net/http" "github.com/gamidoc/backend/config" + "github.com/gamidoc/backend/internal/ai" "github.com/gamidoc/backend/internal/mailer" "github.com/gamidoc/backend/internal/storage/objectstore" ) @@ -31,6 +33,17 @@ func NewObjectStore(cfg config.Config) (objectstore.ObjectStore, error) { } } +func NewAssistant(cfg config.Config) (ai.Assistant, error) { + switch cfg.AIProviderNormalized() { + case "noop": + return ai.NewNoopAssistant(), nil + case "openai-compatible": + return ai.NewOpenAIAssistant(cfg.AIBaseURL, cfg.AIAPIKey, cfg.AIModel, &http.Client{Timeout: cfg.AITimeout}), nil + default: + return nil, fmt.Errorf("unsupported ai provider: %s", cfg.AIProvider) + } +} + func NewMailer(cfg config.Config) (mailer.Mailer, error) { switch cfg.MailerProviderNormalized() { case "noop": diff --git a/internal/cli/doctor.go b/internal/cli/doctor.go index 5e353aa..8f4de16 100644 --- a/internal/cli/doctor.go +++ b/internal/cli/doctor.go @@ -35,6 +35,7 @@ func newDoctorCommand() *cobra.Command { check("config.core", cfg.ValidateCore) check("config.object_storage", cfg.ValidateObjectStorage) check("config.mailer", cfg.ValidateMailer) + check("config.ai", cfg.ValidateAI) check("migrations.dir", func() error { info, err := os.Stat(cfg.MigrationsDir) if err != nil { @@ -90,6 +91,10 @@ func newDoctorCommand() *cobra.Command { _, err := bootstrap.NewMailer(cfg) return err }) + check("ai.init", func() error { + _, err := bootstrap.NewAssistant(cfg) + return err + }) if failed { return errors.New("doctor failed") diff --git a/internal/design/domain.go b/internal/design/domain.go new file mode 100644 index 0000000..05d7303 --- /dev/null +++ b/internal/design/domain.go @@ -0,0 +1,157 @@ +package design + +import ( + "encoding/json" + "errors" + "strconv" + "time" +) + +const SectionCount = 7 + +const ( + PathExperienceFirst = "A" + PathMechanicsFirst = "B" +) + +const ( + SectionStatusNotStarted = "not_started" + SectionStatusInProgress = "in_progress" + SectionStatusComplete = "complete" +) + +const ( + ReportVersionStandard = "standard" + ReportVersionEnhanced = "enhanced" +) + +var ErrInvalidSectionNumber = errors.New("invalid section number") +var ErrInvalidSectionData = errors.New("invalid section data") +var ErrInvalidPath = errors.New("invalid path") +var ErrPathAlreadyChosen = errors.New("path already chosen") +var ErrPathNotChosen = errors.New("path not chosen") +var ErrSectionLocked = errors.New("section locked") +var ErrDashboardLocked = errors.New("dashboard locked") +var ErrInvalidReportVersion = errors.New("invalid report version") + +var sectionNames = map[int]string{ + 1: "Context", + 2: "Experience Timeline", + 3: "Personification & Dynamics", + 4: "Gameful Core", + 5: "Technology", + 6: "Impacts & Benefits", + 7: "Evaluation & Feedback", +} + +var sectionDescriptions = map[int]string{ + 1: "The domain, target audience, and problem the gamified system addresses.", + 2: "How the experience unfolds over time, from first contact to long-term use.", + 3: "Personas or player types and the social dynamics between them.", + 4: "The central game mechanics, rules, and reward structures.", + 5: "Platforms, devices, integrations, and technical constraints.", + 6: "The intended behavioural, learning, or business outcomes.", + 7: "How the system's effect is measured and how feedback reaches users.", +} + +func SectionName(number int) string { + return sectionNames[number] +} + +func SectionDescription(number int) string { + return sectionDescriptions[number] +} + +func Order(path string) []int { + switch path { + case PathExperienceFirst: + return []int{1, 2, 3, 4, 5, 6, 7} + case PathMechanicsFirst: + return []int{1, 4, 5, 2, 3, 6, 7} + default: + return nil + } +} + +type SectionState struct { + Content json.RawMessage `json:"content,omitempty"` + Complete bool `json:"complete"` + Visited bool `json:"visited"` +} + +type Status struct { + Spark string `json:"spark,omitempty"` + Path string `json:"path,omitempty"` + Cursor int `json:"cursor"` + FirstPassDone bool `json:"firstPassDone"` + Sections map[string]SectionState `json:"sections"` + Reports []Report `json:"reports,omitempty"` +} + +func NewInitialStatus() Status { + return Status{ + Cursor: 0, + Sections: map[string]SectionState{}, + } +} + +func (s Status) Section(number int) SectionState { + return s.Sections[SectionKey(number)] +} + +func (s Status) HasContent() bool { + for _, state := range s.Sections { + if len(state.Content) > 0 { + return true + } + } + return false +} + +func SectionKey(number int) string { + return strconv.Itoa(number) +} + +func SectionStatus(state SectionState) string { + if len(state.Content) == 0 { + return SectionStatusNotStarted + } + if state.Complete { + return SectionStatusComplete + } + return SectionStatusInProgress +} + +func SectionPercent(state SectionState) int { + switch SectionStatus(state) { + case SectionStatusComplete: + return 100 + case SectionStatusInProgress: + return 50 + default: + return 0 + } +} + +type DashboardSection struct { + SectionNumber int `json:"sectionNumber"` + Name string `json:"name"` + Description string `json:"description"` + Status string `json:"status"` + Percent int `json:"percent"` +} + +type Dashboard struct { + Sections []DashboardSection `json:"sections"` + OverallPercent int `json:"overallPercent"` + FirstPassDone bool `json:"firstPassDone"` + Path string `json:"path,omitempty"` +} + +type Report struct { + ID string `json:"reportId"` + ProjectID string `json:"-"` + Version string `json:"version"` + URL string `json:"url"` + CreatedAt time.Time `json:"createdAt"` +} diff --git a/internal/design/faq.go b/internal/design/faq.go new file mode 100644 index 0000000..83936bc --- /dev/null +++ b/internal/design/faq.go @@ -0,0 +1,41 @@ +package design + +type FAQEntry struct { + Question string `json:"question"` + Answer string `json:"answer"` +} + +var sectionFAQ = map[int][]FAQEntry{ + 1: { + {Question: "What belongs in the Context section?", Answer: "The domain, the target audience, and the concrete problem the gamified system should address."}, + {Question: "How specific should the audience be?", Answer: "Name a real group with shared traits, such as commuting students or warehouse staff, rather than a generic public."}, + }, + 2: { + {Question: "What is the Experience Timeline?", Answer: "A description of how a user meets and uses the system over time: onboarding, a typical session, and long-term evolution."}, + {Question: "How far ahead should the timeline reach?", Answer: "Cover at least the first contact, the steady state, and what keeps the experience alive after the novelty fades."}, + }, + 3: { + {Question: "What are personification and dynamics?", Answer: "The personas or player types you design for, and the social or competitive dynamics between them."}, + {Question: "Do I need formal player typologies?", Answer: "No. Informal personas grounded in your audience work; typologies such as HEXAD can help structure them."}, + }, + 4: { + {Question: "What is the Gameful Core?", Answer: "The central mechanics of the system: rules, goals, challenges, and reward structures."}, + {Question: "How many mechanics should I define?", Answer: "Start from the few mechanics that carry the core loop; supporting mechanics can be added once the loop is clear."}, + }, + 5: { + {Question: "What goes into Technology?", Answer: "Platforms, devices, integrations, data flows, and the technical constraints that shape the design."}, + {Question: "Should I fix the stack here?", Answer: "Name the constraints that are already fixed and mark open choices explicitly."}, + }, + 6: { + {Question: "What are Impacts and Benefits?", Answer: "The behavioural, learning, or business outcomes the system intends to produce."}, + {Question: "How do I phrase an impact?", Answer: "As an observable change with a direction, such as more weekly active-travel trips, rather than an abstract goal."}, + }, + 7: { + {Question: "What belongs in Evaluation and Feedback?", Answer: "How the system's effect will be measured, and how feedback loops reach the users."}, + {Question: "What makes a good evaluation plan?", Answer: "A measurable outcome, a comparison point, and a decision that depends on the result."}, + }, +} + +func SectionFAQ(number int) []FAQEntry { + return sectionFAQ[number] +} diff --git a/internal/design/handler.go b/internal/design/handler.go new file mode 100644 index 0000000..8b61243 --- /dev/null +++ b/internal/design/handler.go @@ -0,0 +1,533 @@ +package design + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strconv" + + "github.com/gamidoc/backend/internal/ai" + appmiddleware "github.com/gamidoc/backend/internal/http/middleware" + "github.com/gamidoc/backend/internal/http/response" + "github.com/gamidoc/backend/internal/project" + "github.com/gamidoc/backend/internal/session" + "github.com/go-chi/chi/v5" +) + +type SessionGuard interface { + FindByID(ctx context.Context, id string) (session.Session, error) +} + +type ProjectGuard interface { + FindByID(ctx context.Context, id string) (project.Project, error) +} + +type Handler struct { + service *Service + assistant ai.Assistant + reports *ReportService + sessions SessionGuard + sessionStates StateStore + projects ProjectGuard + projectStates StateStore +} + +func NewHandler( + service *Service, + assistant ai.Assistant, + reports *ReportService, + sessions SessionGuard, + sessionStates StateStore, + projects ProjectGuard, + projectStates StateStore, +) *Handler { + return &Handler{ + service: service, + assistant: assistant, + reports: reports, + sessions: sessions, + sessionStates: sessionStates, + projects: projects, + projectStates: projectStates, + } +} + +type owner struct { + kind string + id string + states StateStore +} + +func (h *Handler) SessionRoutes() chi.Router { + r := chi.NewRouter() + h.mountShared(r, h.forSession) + return r +} + +func (h *Handler) ProjectRoutes() chi.Router { + r := chi.NewRouter() + h.mountShared(r, h.forProject) + r.Get("/reports", h.forProject(h.listReports)) + r.Post("/import-session", h.forProject(h.importSession)) + return r +} + +func (h *Handler) mountShared(r chi.Router, guard func(func(http.ResponseWriter, *http.Request, owner)) http.HandlerFunc) { + r.Get("/", guard(h.getState)) + r.Put("/spark", guard(h.saveSpark)) + r.Get("/branch", guard(h.branch)) + r.Post("/path", guard(h.choosePath)) + r.Put("/section/{sectionNumber}", guard(h.saveSection)) + r.Get("/dashboard", guard(h.dashboard)) + r.Post("/generate-pdf", guard(h.generatePDF)) + r.Get("/faq/{sectionNumber}", guard(h.faq)) + r.Post("/ai/rewrite", guard(h.rewrite)) + r.Post("/ai/chat", guard(h.chat)) +} + +func (h *Handler) forSession(next func(http.ResponseWriter, *http.Request, owner)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + sessionID := chi.URLParam(r, "sessionId") + if sessionID == "" { + response.WriteError(w, http.StatusBadRequest, "INVALID_SESSION_ID", "Invalid session id", nil) + return + } + + if _, err := h.sessions.FindByID(r.Context(), sessionID); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + response.WriteError(w, http.StatusNotFound, "SESSION_NOT_FOUND", "Session not found", nil) + return + } + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + next(w, r, owner{kind: "sessions", id: sessionID, states: h.sessionStates}) + } +} + +func (h *Handler) forProject(next func(http.ResponseWriter, *http.Request, owner)) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + userID := appmiddleware.GetAuthUserID(r.Context()) + if userID == "" { + response.WriteError(w, http.StatusUnauthorized, "UNAUTHORIZED", "Unauthorized", nil) + return + } + + projectID := chi.URLParam(r, "projectId") + if projectID == "" { + response.WriteError(w, http.StatusBadRequest, "INVALID_PROJECT_ID", "Invalid project id", nil) + return + } + + found, err := h.projects.FindByID(r.Context(), projectID) + if err != nil { + if errors.Is(err, project.ErrProjectNotFound) { + response.WriteError(w, http.StatusNotFound, "PROJECT_NOT_FOUND", "Project not found", nil) + return + } + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if found.UserID != userID { + response.WriteError(w, http.StatusForbidden, "FORBIDDEN", "Project does not belong to user", nil) + return + } + + next(w, r, owner{kind: "projects", id: projectID, states: h.projectStates}) + } +} + +func decodeOptional(r *http.Request, target any) error { + err := json.NewDecoder(r.Body).Decode(target) + if err != nil && errors.Is(err, io.EOF) { + return nil + } + return err +} + +func (h *Handler) getState(w http.ResponseWriter, r *http.Request, o owner) { + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, status) +} + +func (h *Handler) saveSpark(w http.ResponseWriter, r *http.Request, o owner) { + var input struct { + Spark string `json:"spark"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + var prefill map[int]json.RawMessage + var prefillErr error + if input.Spark != "" { + prefill, prefillErr = h.assistant.Prefill(r.Context(), input.Spark, sectionsMeta()) + } + + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + status = h.service.SaveSpark(status, input.Spark) + + applied := false + if prefillErr == nil && len(prefill) > 0 { + converted := make(map[string]json.RawMessage, len(prefill)) + for number, content := range prefill { + converted[SectionKey(number)] = content + } + status = h.service.ApplyPrefill(status, converted) + applied = true + } + + if err := o.states.Save(r.Context(), o.id, status); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "designStatus": status, + "prefillApplied": applied, + "prefillFailed": prefillErr != nil, + }) +} + +func (h *Handler) branch(w http.ResponseWriter, r *http.Request, o owner) { + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if status.Spark == "" { + response.WriteJSON(w, http.StatusOK, map[string]any{ + "recommendedPath": PathExperienceFirst, + "basis": "default", + }) + return + } + + recommended, err := h.assistant.RecommendBranch(r.Context(), status.Spark) + if err != nil { + response.WriteError(w, http.StatusBadGateway, "AI_PROVIDER_ERROR", "AI provider error", nil) + return + } + if recommended != PathMechanicsFirst { + recommended = PathExperienceFirst + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "recommendedPath": recommended, + "basis": "spark", + }) +} + +func (h *Handler) choosePath(w http.ResponseWriter, r *http.Request, o owner) { + var input struct { + Path string `json:"path"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + updated, err := h.service.ChoosePath(status, input.Path) + if err != nil { + switch { + case errors.Is(err, ErrInvalidPath): + response.WriteError(w, http.StatusBadRequest, "INVALID_PATH", "Invalid path", nil) + case errors.Is(err, ErrPathAlreadyChosen): + response.WriteError(w, http.StatusConflict, "PATH_ALREADY_CHOSEN", "Path already chosen", nil) + case errors.Is(err, ErrSectionLocked): + response.WriteError(w, http.StatusBadRequest, "SECTION_LOCKED", "Complete the Context section first", nil) + default: + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + } + return + } + + if err := o.states.Save(r.Context(), o.id, updated); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, updated) +} + +func (h *Handler) saveSection(w http.ResponseWriter, r *http.Request, o owner) { + sectionValue := chi.URLParam(r, "sectionNumber") + sectionNumber, err := strconv.Atoi(sectionValue) + if err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_SECTION_NUMBER", "Invalid section number", nil) + return + } + + var input struct { + Content json.RawMessage `json:"content"` + Complete *bool `json:"complete"` + Skip bool `json:"skip"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + updated, err := h.service.SaveSection(status, sectionNumber, input.Content, input.Complete, input.Skip) + if err != nil { + switch { + case errors.Is(err, ErrInvalidSectionNumber): + response.WriteError(w, http.StatusBadRequest, "INVALID_SECTION_NUMBER", "Invalid section number", nil) + case errors.Is(err, ErrInvalidSectionData): + response.WriteError(w, http.StatusBadRequest, "INVALID_SECTION_DATA", "Invalid section data", nil) + case errors.Is(err, ErrSectionLocked): + response.WriteError(w, http.StatusBadRequest, "SECTION_LOCKED", "Sections must be traversed in order on the first pass", nil) + case errors.Is(err, ErrPathNotChosen): + response.WriteError(w, http.StatusBadRequest, "PATH_NOT_CHOSEN", "Choose a path after the Context section", nil) + default: + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + } + return + } + + if err := o.states.Save(r.Context(), o.id, updated); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "sectionNumber": sectionNumber, + "section": updated.Section(sectionNumber), + "designStatus": updated, + }) +} + +func (h *Handler) dashboard(w http.ResponseWriter, r *http.Request, o owner) { + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + dashboard, err := h.service.Dashboard(status) + if err != nil { + if errors.Is(err, ErrDashboardLocked) { + response.WriteError(w, http.StatusForbidden, "DASHBOARD_LOCKED", "Complete a full first pass with at least one filled section first", nil) + return + } + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, dashboard) +} + +func (h *Handler) generatePDF(w http.ResponseWriter, r *http.Request, o owner) { + var input struct{} + if err := decodeOptional(r, &input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + status, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if _, err := h.service.Dashboard(status); err != nil { + response.WriteError(w, http.StatusForbidden, "DASHBOARD_LOCKED", "Complete a full first pass with at least one filled section first", nil) + return + } + + generated, err := h.reports.Generate(r.Context(), o.kind, o.id, status) + if err != nil { + if errors.Is(err, ErrAssistant) { + response.WriteError(w, http.StatusBadGateway, "AI_PROVIDER_ERROR", "AI provider error", nil) + return + } + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if o.kind == "sessions" { + status.Reports = append(status.Reports, generated.Standard, generated.Enhanced) + if err := o.states.Save(r.Context(), o.id, status); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + } + + response.WriteJSON(w, http.StatusOK, generated) +} + +func (h *Handler) listReports(w http.ResponseWriter, r *http.Request, o owner) { + reports, err := h.reports.List(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "reports": reports, + "total": len(reports), + }) +} + +func (h *Handler) importSession(w http.ResponseWriter, r *http.Request, o owner) { + var input struct { + SessionID string `json:"sessionId"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil || input.SessionID == "" { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + incoming, err := h.sessionStates.Get(r.Context(), input.SessionID) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if !incoming.HasContent() && incoming.Spark == "" { + if _, err := h.sessions.FindByID(r.Context(), input.SessionID); err != nil { + if errors.Is(err, session.ErrSessionNotFound) { + response.WriteError(w, http.StatusNotFound, "SESSION_NOT_FOUND", "Session not found", nil) + return + } + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + response.WriteError(w, http.StatusConflict, "SESSION_DESIGN_EMPTY", "Session has no design content to import", nil) + return + } + + existing, err := o.states.Get(r.Context(), o.id) + if err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + if existing.HasContent() { + response.WriteError(w, http.StatusConflict, "DESIGN_NOT_EMPTY", "Project already has design content", nil) + return + } + + if len(incoming.Reports) > 0 { + if err := h.reports.Persist(r.Context(), o.id, incoming.Reports); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + incoming.Reports = nil + } + + if err := o.states.Save(r.Context(), o.id, incoming); err != nil { + response.WriteError(w, http.StatusInternalServerError, "INTERNAL_SERVER_ERROR", "Internal server error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, incoming) +} + +func (h *Handler) faq(w http.ResponseWriter, r *http.Request, o owner) { + sectionValue := chi.URLParam(r, "sectionNumber") + sectionNumber, err := strconv.Atoi(sectionValue) + if err != nil || sectionNumber < 1 || sectionNumber > SectionCount { + response.WriteError(w, http.StatusBadRequest, "INVALID_SECTION_NUMBER", "Invalid section number", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "sectionNumber": sectionNumber, + "faq": SectionFAQ(sectionNumber), + }) +} + +func (h *Handler) rewrite(w http.ResponseWriter, r *http.Request, o owner) { + var input struct { + Text string `json:"text"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + + rewritten, err := h.assistant.Rewrite(r.Context(), input.Text) + if err != nil { + if errors.Is(err, ai.ErrEmptyText) { + response.WriteError(w, http.StatusBadRequest, "EMPTY_TEXT", "Text is required", nil) + return + } + response.WriteError(w, http.StatusBadGateway, "AI_PROVIDER_ERROR", "AI provider error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "text": rewritten, + "previousText": input.Text, + }) +} + +func (h *Handler) chat(w http.ResponseWriter, r *http.Request, o owner) { + var input struct { + SectionNumber int `json:"sectionNumber"` + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + response.WriteError(w, http.StatusBadRequest, "INVALID_INPUT", "Invalid request body", nil) + return + } + if input.SectionNumber < 1 || input.SectionNumber > SectionCount { + response.WriteError(w, http.StatusBadRequest, "INVALID_SECTION_NUMBER", "Invalid section number", nil) + return + } + + reply, err := h.assistant.Chat(r.Context(), ai.Section{ + Number: input.SectionNumber, + Name: SectionName(input.SectionNumber), + }, input.Message) + if err != nil { + response.WriteError(w, http.StatusBadGateway, "AI_PROVIDER_ERROR", "AI provider error", nil) + return + } + + response.WriteJSON(w, http.StatusOK, map[string]any{ + "sectionNumber": input.SectionNumber, + "reply": reply, + "faq": SectionFAQ(input.SectionNumber), + }) +} + +func sectionsMeta() []ai.Section { + var sections []ai.Section + for number := 1; number <= SectionCount; number++ { + sections = append(sections, ai.Section{ + Number: number, + Name: SectionName(number), + }) + } + return sections +} diff --git a/internal/design/report.go b/internal/design/report.go new file mode 100644 index 0000000..ebd34fa --- /dev/null +++ b/internal/design/report.go @@ -0,0 +1,160 @@ +package design + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/go-pdf/fpdf" +) + +type RenderSection struct { + Number int + Name string + Lines []string +} + +type ReportBuilder struct{} + +func NewReportBuilder() *ReportBuilder { + return &ReportBuilder{} +} + +func (b *ReportBuilder) Build(title string, version string, spark string, sections []RenderSection) ([]byte, error) { + doc := fpdf.New("P", "mm", "A4", "") + doc.SetTitle(title, false) + doc.AddPage() + + doc.SetFont("Arial", "B", 16) + doc.MultiCell(0, 8, title, "", "L", false) + doc.Ln(2) + + doc.SetFont("Arial", "", 10) + doc.Cell(0, 6, "Version: "+version) + doc.Ln(6) + doc.Cell(0, 6, "Date: "+time.Now().UTC().Format("2006-01-02 15:04:05")) + doc.Ln(10) + + if strings.TrimSpace(spark) != "" { + doc.SetFont("Arial", "B", 13) + doc.Cell(0, 8, "The Spark") + doc.Ln(8) + doc.SetFont("Arial", "", 11) + doc.MultiCell(0, 6, spark, "", "L", false) + doc.Ln(4) + } + + for _, section := range sections { + doc.SetFont("Arial", "B", 13) + doc.Cell(0, 8, fmt.Sprintf("%d. %s", section.Number, section.Name)) + doc.Ln(8) + doc.SetFont("Arial", "", 11) + if len(section.Lines) == 0 { + doc.MultiCell(0, 6, "Not provided.", "", "L", false) + } + for _, line := range section.Lines { + doc.MultiCell(0, 6, line, "", "L", false) + } + doc.Ln(4) + } + + var buffer bytes.Buffer + if err := doc.Output(&buffer); err != nil { + return nil, err + } + + return buffer.Bytes(), nil +} + +func SectionLines(content json.RawMessage) []string { + if len(content) == 0 { + return nil + } + + decoder := json.NewDecoder(bytes.NewReader(content)) + decoder.UseNumber() + token, err := decoder.Token() + if err != nil { + return []string{string(content)} + } + + if delim, ok := token.(json.Delim); ok && delim == '{' { + var lines []string + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return []string{string(content)} + } + value, err := renderValue(decoder, true) + if err != nil { + return []string{string(content)} + } + lines = append(lines, fmt.Sprintf("%v: %s", keyToken, value)) + } + return lines + } + + var text string + if err := json.Unmarshal(content, &text); err == nil { + return []string{text} + } + + return []string{string(content)} +} + +func SectionPlainText(content json.RawMessage) string { + return strings.Join(SectionLines(content), "\n") +} + +func renderValue(decoder *json.Decoder, bareList bool) (string, error) { + token, err := decoder.Token() + if err != nil { + return "", err + } + + switch typed := token.(type) { + case json.Delim: + var parts []string + if typed == '{' { + for decoder.More() { + keyToken, err := decoder.Token() + if err != nil { + return "", err + } + value, err := renderValue(decoder, false) + if err != nil { + return "", err + } + parts = append(parts, fmt.Sprintf("%v: %s", keyToken, value)) + } + if _, err := decoder.Token(); err != nil { + return "", err + } + return "{" + strings.Join(parts, ", ") + "}", nil + } + for decoder.More() { + value, err := renderValue(decoder, false) + if err != nil { + return "", err + } + parts = append(parts, value) + } + if _, err := decoder.Token(); err != nil { + return "", err + } + if bareList { + return strings.Join(parts, ", "), nil + } + return "[" + strings.Join(parts, ", ") + "]", nil + case json.Number: + return typed.String(), nil + case string: + return typed, nil + case nil: + return "", nil + default: + return fmt.Sprint(typed), nil + } +} diff --git a/internal/design/report_test.go b/internal/design/report_test.go new file mode 100644 index 0000000..6aa305f --- /dev/null +++ b/internal/design/report_test.go @@ -0,0 +1,52 @@ +package design + +import ( + "encoding/json" + "testing" +) + +func TestSectionLinesPreservesNumbersAndOrder(t *testing.T) { + content := json.RawMessage(`{"zeta":"first","budget":1500000,"alpha":{"y":1,"x":2},"tags":["b","a"],"big":9007199254740993}`) + + lines := SectionLines(content) + + expected := []string{ + "zeta: first", + "budget: 1500000", + "alpha: {y: 1, x: 2}", + "tags: b, a", + "big: 9007199254740993", + } + if len(lines) != len(expected) { + t.Fatalf("expected %d lines, got %d: %v", len(expected), len(lines), lines) + } + for i, want := range expected { + if lines[i] != want { + t.Fatalf("line %d: expected %q, got %q", i, want, lines[i]) + } + } +} + +func TestSectionLinesNestedArrays(t *testing.T) { + lines := SectionLines(json.RawMessage(`{"grid":[[1,2],[3,4]],"flag":true,"none":null}`)) + + expected := []string{ + "grid: [1, 2], [3, 4]", + "flag: true", + "none: ", + } + for i, want := range expected { + if lines[i] != want { + t.Fatalf("line %d: expected %q, got %q", i, want, lines[i]) + } + } +} + +func TestSectionLinesScalarForms(t *testing.T) { + if lines := SectionLines(json.RawMessage(`"just text"`)); len(lines) != 1 || lines[0] != "just text" { + t.Fatalf("unexpected string handling: %v", lines) + } + if lines := SectionLines(nil); lines != nil { + t.Fatalf("expected nil for empty content, got %v", lines) + } +} diff --git a/internal/design/reports.go b/internal/design/reports.go new file mode 100644 index 0000000..f56e4a2 --- /dev/null +++ b/internal/design/reports.go @@ -0,0 +1,157 @@ +package design + +import ( + "context" + "errors" + "fmt" + "time" + + "github.com/gamidoc/backend/internal/ai" + "github.com/gamidoc/backend/internal/storage/objectstore" + "github.com/google/uuid" +) + +var ErrAssistant = errors.New("assistant error") + +type GeneratedReports struct { + Standard Report `json:"standard"` + Enhanced Report `json:"enhanced"` +} + +type ReportService struct { + assistant ai.Assistant + builder *ReportBuilder + store objectstore.ObjectStore + repo ReportRepository +} + +func NewReportService(assistant ai.Assistant, builder *ReportBuilder, store objectstore.ObjectStore, repo ReportRepository) *ReportService { + return &ReportService{ + assistant: assistant, + builder: builder, + store: store, + repo: repo, + } +} + +func (r *ReportService) Generate(ctx context.Context, kind string, id string, status Status) (GeneratedReports, error) { + standardSections := renderStandard(status) + enhancedSections, err := r.renderEnhanced(ctx, status) + if err != nil { + return GeneratedReports{}, fmt.Errorf("%w: %v", ErrAssistant, err) + } + + standard, err := r.produce(ctx, kind, id, status, ReportVersionStandard, standardSections) + if err != nil { + return GeneratedReports{}, err + } + + enhanced, err := r.produce(ctx, kind, id, status, ReportVersionEnhanced, enhancedSections) + if err != nil { + return GeneratedReports{}, err + } + + return GeneratedReports{Standard: standard, Enhanced: enhanced}, nil +} + +func (r *ReportService) produce(ctx context.Context, kind string, id string, status Status, version string, sections []RenderSection) (Report, error) { + data, err := r.builder.Build("Gamification Design Report", version, status.Spark, sections) + if err != nil { + return Report{}, err + } + + reportID := uuid.NewString() + key := "design/" + kind + "/" + id + "/" + reportID + ".pdf" + url, err := r.store.Save(ctx, key, data) + if err != nil { + return Report{}, err + } + + report := Report{ + ID: reportID, + Version: version, + URL: url, + CreatedAt: time.Now().UTC(), + } + + if kind == "projects" { + report.ProjectID = id + created, err := r.repo.Create(ctx, report) + if err != nil { + _ = r.store.Delete(ctx, key) + return Report{}, err + } + return created, nil + } + + return report, nil +} + +func (r *ReportService) List(ctx context.Context, projectID string) ([]Report, error) { + return r.repo.ListByProjectID(ctx, projectID) +} + +func (r *ReportService) Persist(ctx context.Context, projectID string, reports []Report) error { + for _, report := range reports { + report.ID = uuid.NewString() + report.ProjectID = projectID + if report.CreatedAt.IsZero() { + report.CreatedAt = time.Now().UTC() + } + if _, err := r.repo.Create(ctx, report); err != nil { + return err + } + } + return nil +} + +func renderStandard(status Status) []RenderSection { + var sections []RenderSection + for number := 1; number <= SectionCount; number++ { + sections = append(sections, RenderSection{ + Number: number, + Name: SectionName(number), + Lines: SectionLines(status.Section(number).Content), + }) + } + return sections +} + +func (r *ReportService) renderEnhanced(ctx context.Context, status Status) ([]RenderSection, error) { + var inputs []ai.SectionText + for number := 1; number <= SectionCount; number++ { + text := SectionPlainText(status.Section(number).Content) + if text == "" { + continue + } + inputs = append(inputs, ai.SectionText{ + Number: number, + Name: SectionName(number), + Text: text, + }) + } + + enhanced, err := r.assistant.Enhance(ctx, inputs) + if err != nil { + return nil, err + } + + prose := make(map[int]string, len(enhanced)) + for _, section := range enhanced { + prose[section.Number] = section.Text + } + + var sections []RenderSection + for number := 1; number <= SectionCount; number++ { + var lines []string + if text, ok := prose[number]; ok && text != "" { + lines = []string{text} + } + sections = append(sections, RenderSection{ + Number: number, + Name: SectionName(number), + Lines: lines, + }) + } + return sections, nil +} diff --git a/internal/design/service.go b/internal/design/service.go new file mode 100644 index 0000000..30f02e8 --- /dev/null +++ b/internal/design/service.go @@ -0,0 +1,142 @@ +package design + +import ( + "encoding/json" + "strings" +) + +type Service struct{} + +func NewService() *Service { + return &Service{} +} + +func (s *Service) SaveSpark(current Status, spark string) Status { + current = withSections(current) + current.Spark = strings.TrimSpace(spark) + return current +} + +func (s *Service) ApplyPrefill(current Status, prefill map[string]json.RawMessage) Status { + current = withSections(current) + for number := 1; number <= SectionCount; number++ { + key := SectionKey(number) + content, ok := prefill[key] + if !ok || len(content) == 0 || !json.Valid(content) { + continue + } + state := current.Sections[key] + if len(state.Content) > 0 { + continue + } + state.Content = content + current.Sections[key] = state + } + return current +} + +func (s *Service) ChoosePath(current Status, path string) (Status, error) { + current = withSections(current) + if path != PathExperienceFirst && path != PathMechanicsFirst { + return Status{}, ErrInvalidPath + } + if current.Path != "" { + return Status{}, ErrPathAlreadyChosen + } + if current.Cursor < 1 && !current.FirstPassDone { + return Status{}, ErrSectionLocked + } + current.Path = path + return current, nil +} + +func (s *Service) SaveSection(current Status, number int, content json.RawMessage, complete *bool, skip bool) (Status, error) { + current = withSections(current) + if number < 1 || number > SectionCount { + return Status{}, ErrInvalidSectionNumber + } + + key := SectionKey(number) + state := current.Sections[key] + frontier := !current.FirstPassDone && !state.Visited + + if frontier { + expected, err := s.nextSection(current) + if err != nil { + return Status{}, err + } + if number != expected { + return Status{}, ErrSectionLocked + } + } + + state.Visited = true + + if !skip { + if len(content) == 0 || !json.Valid(content) { + return Status{}, ErrInvalidSectionData + } + state.Content = content + if complete != nil { + state.Complete = *complete + } + } + + current.Sections[key] = state + + if frontier { + current.Cursor++ + if current.Cursor >= SectionCount { + current.FirstPassDone = true + } + } + + return current, nil +} + +func (s *Service) nextSection(current Status) (int, error) { + if current.Cursor == 0 { + return 1, nil + } + order := Order(current.Path) + if order == nil { + return 0, ErrPathNotChosen + } + return order[current.Cursor], nil +} + +func (s *Service) Dashboard(current Status) (Dashboard, error) { + current = withSections(current) + if !current.FirstPassDone || !current.HasContent() { + return Dashboard{}, ErrDashboardLocked + } + + sections := make([]DashboardSection, 0, SectionCount) + total := 0 + for number := 1; number <= SectionCount; number++ { + state := current.Section(number) + percent := SectionPercent(state) + total += percent + sections = append(sections, DashboardSection{ + SectionNumber: number, + Name: SectionName(number), + Description: SectionDescription(number), + Status: SectionStatus(state), + Percent: percent, + }) + } + + return Dashboard{ + Sections: sections, + OverallPercent: total / SectionCount, + FirstPassDone: current.FirstPassDone, + Path: current.Path, + }, nil +} + +func withSections(current Status) Status { + if current.Sections == nil { + current.Sections = map[string]SectionState{} + } + return current +} diff --git a/internal/design/service_test.go b/internal/design/service_test.go new file mode 100644 index 0000000..a93a789 --- /dev/null +++ b/internal/design/service_test.go @@ -0,0 +1,273 @@ +package design + +import ( + "encoding/json" + "errors" + "testing" +) + +func content(t *testing.T, value string) json.RawMessage { + t.Helper() + raw := json.RawMessage(value) + if !json.Valid(raw) { + t.Fatalf("invalid test content: %s", value) + } + return raw +} + +func traverse(t *testing.T, service *Service, path string) Status { + t.Helper() + status := NewInitialStatus() + + status, err := service.SaveSection(status, 1, content(t, `{"draft":"context"}`), nil, false) + if err != nil { + t.Fatal(err) + } + + status, err = service.ChoosePath(status, path) + if err != nil { + t.Fatal(err) + } + + order := Order(path) + for _, number := range order[1:] { + status, err = service.SaveSection(status, number, nil, nil, true) + if err != nil { + t.Fatal(err) + } + } + + return status +} + +func TestFirstPassRequiresSectionOne(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + if _, err := service.SaveSection(status, 2, content(t, `{"a":1}`), nil, false); !errors.Is(err, ErrSectionLocked) { + t.Fatalf("expected ErrSectionLocked, got %v", err) + } +} + +func TestPathGate(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + if _, err := service.ChoosePath(status, PathExperienceFirst); !errors.Is(err, ErrSectionLocked) { + t.Fatalf("expected ErrSectionLocked, got %v", err) + } + + status, err := service.SaveSection(status, 1, content(t, `{"draft":"context"}`), nil, false) + if err != nil { + t.Fatal(err) + } + + if _, err := service.SaveSection(status, 2, content(t, `{"a":1}`), nil, false); !errors.Is(err, ErrPathNotChosen) { + t.Fatalf("expected ErrPathNotChosen, got %v", err) + } + + status, err = service.ChoosePath(status, PathMechanicsFirst) + if err != nil { + t.Fatal(err) + } + + if _, err := service.ChoosePath(status, PathExperienceFirst); !errors.Is(err, ErrPathAlreadyChosen) { + t.Fatalf("expected ErrPathAlreadyChosen, got %v", err) + } + + if _, err := service.ChoosePath(NewInitialStatus(), "C"); !errors.Is(err, ErrInvalidPath) { + t.Fatalf("expected ErrInvalidPath, got %v", err) + } +} + +func TestMechanicsFirstOrder(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + status, err := service.SaveSection(status, 1, content(t, `{"draft":"context"}`), nil, false) + if err != nil { + t.Fatal(err) + } + + status, err = service.ChoosePath(status, PathMechanicsFirst) + if err != nil { + t.Fatal(err) + } + + if _, err := service.SaveSection(status, 2, content(t, `{"a":1}`), nil, false); !errors.Is(err, ErrSectionLocked) { + t.Fatalf("expected ErrSectionLocked, got %v", err) + } + + status, err = service.SaveSection(status, 4, content(t, `{"core":"points"}`), boolPtr(true), false) + if err != nil { + t.Fatal(err) + } + + if status.Cursor != 2 { + t.Fatalf("expected cursor 2, got %d", status.Cursor) + } +} + +func TestSkipAdvancesWithoutContent(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + status, err := service.SaveSection(status, 1, nil, nil, true) + if err != nil { + t.Fatal(err) + } + + if status.Cursor != 1 { + t.Fatalf("expected cursor 1, got %d", status.Cursor) + } + if len(status.Section(1).Content) != 0 { + t.Fatal("expected no content after skip") + } + if !status.Section(1).Visited { + t.Fatal("expected section marked visited") + } +} + +func TestTraversalUnlocksFreeNavigation(t *testing.T) { + service := NewService() + status := traverse(t, service, PathExperienceFirst) + + if !status.FirstPassDone { + t.Fatal("expected first pass done") + } + + status, err := service.SaveSection(status, 6, content(t, `{"impact":"co2"}`), boolPtr(true), false) + if err != nil { + t.Fatal(err) + } + if SectionStatus(status.Section(6)) != SectionStatusComplete { + t.Fatal("expected section 6 complete") + } +} + +func TestInvalidSectionData(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + if _, err := service.SaveSection(status, 1, json.RawMessage(`{invalid`), nil, false); !errors.Is(err, ErrInvalidSectionData) { + t.Fatalf("expected ErrInvalidSectionData, got %v", err) + } +} + +func TestDashboardGateAndPercentages(t *testing.T) { + service := NewService() + + if _, err := service.Dashboard(NewInitialStatus()); !errors.Is(err, ErrDashboardLocked) { + t.Fatalf("expected ErrDashboardLocked, got %v", err) + } + + status := traverse(t, service, PathExperienceFirst) + status, err := service.SaveSection(status, 2, content(t, `{"timeline":"weekly"}`), boolPtr(true), false) + if err != nil { + t.Fatal(err) + } + + dashboard, err := service.Dashboard(status) + if err != nil { + t.Fatal(err) + } + + if len(dashboard.Sections) != SectionCount { + t.Fatalf("expected %d sections, got %d", SectionCount, len(dashboard.Sections)) + } + if dashboard.Sections[0].Status != SectionStatusInProgress { + t.Fatalf("expected section 1 in progress, got %s", dashboard.Sections[0].Status) + } + if dashboard.Sections[1].Status != SectionStatusComplete { + t.Fatalf("expected section 2 complete, got %s", dashboard.Sections[1].Status) + } + if dashboard.OverallPercent != (50+100)/SectionCount { + t.Fatalf("unexpected overall percent %d", dashboard.OverallPercent) + } +} + +func TestApplyPrefillFillsOnlyEmptySections(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + status, err := service.SaveSection(status, 1, content(t, `{"draft":"mine"}`), nil, false) + if err != nil { + t.Fatal(err) + } + + status = service.ApplyPrefill(status, map[string]json.RawMessage{ + "1": content(t, `{"draft":"generated"}`), + "2": content(t, `{"draft":"generated"}`), + }) + + if string(status.Section(1).Content) != `{"draft":"mine"}` { + t.Fatal("expected user content preserved") + } + if string(status.Section(2).Content) != `{"draft":"generated"}` { + t.Fatal("expected empty section prefilled") + } +} + +func TestSaveSparkTrims(t *testing.T) { + service := NewService() + status := service.SaveSpark(NewInitialStatus(), " a gamified commuting app ") + + if status.Spark != "a gamified commuting app" { + t.Fatalf("unexpected spark %q", status.Spark) + } +} + +func boolPtr(value bool) *bool { + return &value +} + +func TestFirstPassAllowsResaveOfVisitedSections(t *testing.T) { + service := NewService() + status := NewInitialStatus() + + status, err := service.SaveSection(status, 1, content(t, `{"draft":"v1"}`), nil, false) + if err != nil { + t.Fatal(err) + } + + status, err = service.SaveSection(status, 1, content(t, `{"draft":"v2"}`), nil, false) + if err != nil { + t.Fatal(err) + } + if status.Cursor != 1 { + t.Fatalf("expected cursor to stay at 1, got %d", status.Cursor) + } + if string(status.Section(1).Content) != `{"draft":"v2"}` { + t.Fatal("expected re-save to update content") + } + + if _, err := service.SaveSection(status, 3, content(t, `{"a":1}`), nil, false); !errors.Is(err, ErrPathNotChosen) { + t.Fatalf("expected ErrPathNotChosen for the frontier, got %v", err) + } +} + +func TestCompletePointerPreservesFlag(t *testing.T) { + service := NewService() + status := traverse(t, service, PathExperienceFirst) + + status, err := service.SaveSection(status, 2, content(t, `{"a":1}`), boolPtr(true), false) + if err != nil { + t.Fatal(err) + } + + status, err = service.SaveSection(status, 2, content(t, `{"a":2}`), nil, false) + if err != nil { + t.Fatal(err) + } + if !status.Section(2).Complete { + t.Fatal("expected omitted complete flag to preserve completion") + } + + status, err = service.SaveSection(status, 2, content(t, `{"a":3}`), boolPtr(false), false) + if err != nil { + t.Fatal(err) + } + if status.Section(2).Complete { + t.Fatal("expected explicit false to clear completion") + } +} diff --git a/internal/design/storage.go b/internal/design/storage.go new file mode 100644 index 0000000..1a58deb --- /dev/null +++ b/internal/design/storage.go @@ -0,0 +1,13 @@ +package design + +import "context" + +type StateStore interface { + Get(ctx context.Context, id string) (Status, error) + Save(ctx context.Context, id string, status Status) error +} + +type ReportRepository interface { + Create(ctx context.Context, report Report) (Report, error) + ListByProjectID(ctx context.Context, projectID string) ([]Report, error) +} diff --git a/internal/http/middleware/activity.go b/internal/http/middleware/activity.go index a392d4d..ff7cf97 100644 --- a/internal/http/middleware/activity.go +++ b/internal/http/middleware/activity.go @@ -126,6 +126,20 @@ func requestEventType(method string, path string, status int) string { return activity.EventAPIRequest } + if containsSegment(segments, "design") { + switch { + case method == http.MethodPut && containsSegment(segments, "section"): + return activity.EventDesignSectionSaved + case method == http.MethodPost && lastSegment(segments) == "path": + return activity.EventDesignPathChosen + case method == http.MethodPost && lastSegment(segments) == "generate-pdf": + return activity.EventDesignPDFGenerated + case method == http.MethodPost && lastSegment(segments) == "import-session": + return activity.EventDesignImported + } + return activity.EventAPIRequest + } + if len(segments) >= 4 && segments[2] == "auth" { switch { case method == http.MethodPost && segments[3] == "register": @@ -186,6 +200,15 @@ func pathSegments(path string) []string { return strings.Split(trimmed, "/") } +func containsSegment(segments []string, value string) bool { + for _, segment := range segments { + if segment == value { + return true + } + } + return false +} + func hasPathSuffix(segments []string, first string, second string) bool { for i := 0; i < len(segments)-1; i++ { if segments[i] == first && segments[i+1] == second { diff --git a/internal/http/middleware/activity_test.go b/internal/http/middleware/activity_test.go index e37bd6c..7e49be2 100644 --- a/internal/http/middleware/activity_test.go +++ b/internal/http/middleware/activity_test.go @@ -49,6 +49,28 @@ func TestRequestActivityEventExtractsSessionStep(t *testing.T) { } } +func TestRequestActivityEventClassifiesDesignRoutes(t *testing.T) { + cases := []struct { + method string + path string + want string + }{ + {http.MethodPut, "/api/v1/sessions/session-1/design/section/3", activity.EventDesignSectionSaved}, + {http.MethodPost, "/api/v1/projects/project-1/design/path", activity.EventDesignPathChosen}, + {http.MethodPost, "/api/v1/sessions/session-1/design/generate-pdf", activity.EventDesignPDFGenerated}, + {http.MethodPost, "/api/v1/projects/project-1/design/import-session", activity.EventDesignImported}, + {http.MethodGet, "/api/v1/sessions/session-1/design/dashboard", activity.EventAPIRequest}, + } + + for _, c := range cases { + req := httptest.NewRequest(c.method, c.path, nil) + event := requestActivityEvent(req, nil, http.StatusOK, 10*time.Millisecond) + if event.Type != c.want { + t.Fatalf("%s %s: expected %q, got %q", c.method, c.path, c.want, event.Type) + } + } +} + func TestRequestActivityEventMarksFailures(t *testing.T) { req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/register", nil) diff --git a/internal/http/router.go b/internal/http/router.go index f9876ee..e9dd31c 100644 --- a/internal/http/router.go +++ b/internal/http/router.go @@ -8,6 +8,7 @@ import ( "time" "github.com/gamidoc/backend/internal/activity" + "github.com/gamidoc/backend/internal/design" appmiddleware "github.com/gamidoc/backend/internal/http/middleware" "github.com/gamidoc/backend/internal/http/response" "github.com/gamidoc/backend/internal/pdf" @@ -36,6 +37,7 @@ type Dependencies struct { AuthHandler http.Handler ProjectHandler *project.Handler SessionHandler *session.Handler + DesignHandler *design.Handler PDFHandler *pdf.Handler PDFBaseURL string MaxBodyBytes int64 @@ -146,6 +148,11 @@ func NewRouter(deps Dependencies) http.Handler { r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Get("/projects/{projectId}/download-pdf", deps.PDFHandler.ProjectDownload) r.Get("/sessions/{sessionId}/download-pdf", deps.PDFHandler.SessionDownload) } + + if deps.DesignHandler != nil { + r.Mount("/sessions/{sessionId}/design", deps.DesignHandler.SessionRoutes()) + r.With(appmiddleware.RequireAuth(deps.TokenManager, deps.TokenBlacklist)).Mount("/projects/{projectId}/design", deps.DesignHandler.ProjectRoutes()) + } }) return r diff --git a/internal/storage/postgres/design_report_repository.go b/internal/storage/postgres/design_report_repository.go new file mode 100644 index 0000000..2076c89 --- /dev/null +++ b/internal/storage/postgres/design_report_repository.go @@ -0,0 +1,70 @@ +package postgres + +import ( + "context" + + "github.com/gamidoc/backend/internal/design" +) + +type DesignReportRepository struct { + db *DB +} + +func NewDesignReportRepository(db *DB) *DesignReportRepository { + return &DesignReportRepository{db: db} +} + +func (r *DesignReportRepository) Create(ctx context.Context, report design.Report) (design.Report, error) { + row := r.db.sql.QueryRowContext( + ctx, + ` + INSERT INTO design_reports (id, project_id, version, url, created_at) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, project_id, version, url, created_at + `, + report.ID, + report.ProjectID, + report.Version, + report.URL, + report.CreatedAt, + ) + + var created design.Report + if err := row.Scan(&created.ID, &created.ProjectID, &created.Version, &created.URL, &created.CreatedAt); err != nil { + return design.Report{}, err + } + + return created, nil +} + +func (r *DesignReportRepository) ListByProjectID(ctx context.Context, projectID string) ([]design.Report, error) { + rows, err := r.db.sql.QueryContext( + ctx, + ` + SELECT id, project_id, version, url, created_at + FROM design_reports + WHERE project_id = $1 + ORDER BY created_at DESC + `, + projectID, + ) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []design.Report + for rows.Next() { + var found design.Report + if err := rows.Scan(&found.ID, &found.ProjectID, &found.Version, &found.URL, &found.CreatedAt); err != nil { + return nil, err + } + result = append(result, found) + } + + if err := rows.Err(); err != nil { + return nil, err + } + + return result, nil +} diff --git a/internal/storage/postgres/design_state_repository.go b/internal/storage/postgres/design_state_repository.go new file mode 100644 index 0000000..07fb9dd --- /dev/null +++ b/internal/storage/postgres/design_state_repository.go @@ -0,0 +1,67 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + + "github.com/gamidoc/backend/internal/design" +) + +type DesignStateRepository struct { + db *DB +} + +func NewDesignStateRepository(db *DB) *DesignStateRepository { + return &DesignStateRepository{db: db} +} + +func (r *DesignStateRepository) Get(ctx context.Context, id string) (design.Status, error) { + row := r.db.sql.QueryRowContext( + ctx, + ` + SELECT data + FROM design_states + WHERE project_id = $1 + `, + id, + ) + + var data string + if err := row.Scan(&data); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return design.NewInitialStatus(), nil + } + return design.Status{}, err + } + + var status design.Status + if err := json.Unmarshal([]byte(data), &status); err != nil { + return design.Status{}, err + } + if status.Sections == nil { + status.Sections = map[string]design.SectionState{} + } + + return status, nil +} + +func (r *DesignStateRepository) Save(ctx context.Context, id string, status design.Status) error { + data, err := json.Marshal(status) + if err != nil { + return err + } + + _, err = r.db.sql.ExecContext( + ctx, + ` + INSERT INTO design_states (project_id, data, updated_at) + VALUES ($1, $2, NOW()) + ON CONFLICT (project_id) DO UPDATE SET data = $2, updated_at = NOW() + `, + id, + string(data), + ) + return err +} diff --git a/internal/storage/redis/design_repository.go b/internal/storage/redis/design_repository.go new file mode 100644 index 0000000..51a7aa8 --- /dev/null +++ b/internal/storage/redis/design_repository.go @@ -0,0 +1,64 @@ +package redis + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/gamidoc/backend/internal/design" + goredis "github.com/redis/go-redis/v9" +) + +type DesignRepository struct { + client *Client + ttl time.Duration +} + +func NewDesignRepository(client *Client, ttl time.Duration) *DesignRepository { + return &DesignRepository{ + client: client, + ttl: ttl, + } +} + +func (r *DesignRepository) Get(ctx context.Context, id string) (design.Status, error) { + value, err := r.client.Raw().Get(ctx, r.key(id)).Result() + if err != nil { + if errors.Is(err, goredis.Nil) { + return design.NewInitialStatus(), nil + } + return design.Status{}, err + } + + var status design.Status + if err := json.Unmarshal([]byte(value), &status); err != nil { + return design.Status{}, err + } + if status.Sections == nil { + status.Sections = map[string]design.SectionState{} + } + + return status, nil +} + +func (r *DesignRepository) Save(ctx context.Context, id string, status design.Status) error { + payload, err := json.Marshal(status) + if err != nil { + return err + } + + return saveKeepTTL.Run(ctx, r.client.Raw(), []string{r.key(id)}, payload, r.ttl.Milliseconds()).Err() +} + +var saveKeepTTL = goredis.NewScript(` +redis.call('SET', KEYS[1], ARGV[1], 'KEEPTTL') +if redis.call('PTTL', KEYS[1]) < 0 then + redis.call('PEXPIRE', KEYS[1], ARGV[2]) +end +return 1 +`) + +func (r *DesignRepository) key(id string) string { + return "design:" + id +} diff --git a/internal/wizard/service_test.go b/internal/wizard/service_test.go index ce71f4e..51ae117 100644 --- a/internal/wizard/service_test.go +++ b/internal/wizard/service_test.go @@ -27,7 +27,7 @@ func TestSaveStepRejectsInvalidData(t *testing.T) { func TestSaveStepRejectsMissingStep1Fields(t *testing.T) { service := NewService() - _, err := service.SaveStep(NewInitialStatus(), 1, json.RawMessage(`{"evaluationGoals":["Usability & Playability"],"projectType":"","participants":"Limited set of participants","developmentStage":"Concept idea"}`)) + _, err := service.SaveStep(NewInitialStatus(), 1, json.RawMessage(`{"evaluationGoals":["Usability & Playability"],"projectType":"Concept test","participants":"","developmentStage":"Concept idea"}`)) if !errors.Is(err, ErrInvalidStepData) { t.Fatalf("expected ErrInvalidStepData, got %v", err) } diff --git a/migrations/000005_design.sql b/migrations/000005_design.sql new file mode 100644 index 0000000..f730466 --- /dev/null +++ b/migrations/000005_design.sql @@ -0,0 +1,15 @@ +CREATE TABLE IF NOT EXISTS design_states ( + project_id UUID PRIMARY KEY REFERENCES projects(id) ON DELETE CASCADE, + data TEXT NOT NULL DEFAULT '{}', + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS design_reports ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + version TEXT NOT NULL, + url TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS design_reports_project_idx ON design_reports (project_id, created_at DESC);