Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 37 additions & 31 deletions .env.dev.example
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
6 changes: 6 additions & 0 deletions .env.prod.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,5 @@

/api
/docs/reference/*

*.exe
47 changes: 47 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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),
}
}

Expand All @@ -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
}

Expand Down Expand Up @@ -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":
Expand Down Expand Up @@ -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,
}
}
Expand Down
2 changes: 1 addition & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
122 changes: 122 additions & 0 deletions docs/api/design.md
Original file line number Diff line number Diff line change
@@ -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`.
1 change: 1 addition & 0 deletions docs/api/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Binary file removed gamidoc-backend.exe
Binary file not shown.
4 changes: 4 additions & 0 deletions internal/activity/domain.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_"
)

Expand Down
28 changes: 28 additions & 0 deletions internal/ai/assistant.go
Original file line number Diff line number Diff line change
@@ -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)
}
Loading