feat: Infor-style split-panel layout, projects page, fuzzy search, Odoo scaffold

Web UI - Infor CloudSuite-style split-panel layout (items.html rewrite):
- Replace modal-based item detail with inline split-panel workspace
- Horizontal mode: item list on left, tabbed detail panel on right
- Vertical mode: detail panel on top, item list below
- Detail tabs: Main, Properties, Revisions, BOM, Where Used
- Ctrl+F opens in-page filter overlay with fuzzy search
- Column config gear icon with per-layout-mode persistence
- Search scope toggle pills (All / Part Number / Description)
- Selected row highlight with accent border
- Responsive breakpoint forces vertical below 900px
- Create/Edit/Delete remain as modal dialogs

Web UI - Projects page:
- New projects.html template with full CRUD
- Project table: Code, Name, Description, Item count, Created, Actions
- Create/Edit/Delete modals
- Click project code navigates to items filtered by project
- 3-tab navigation in base.html: Items, Projects, Schemas

Fuzzy search:
- Add sahilm/fuzzy dependency for ranked text matching
- New internal/api/search.go with SearchableItems fuzzy.Source
- GET /api/items/search endpoint with field scope and type/project filters
- Frontend routes to fuzzy endpoint when search input is non-empty

Odoo ERP integration scaffold:
- Migration 008: integrations and sync_log tables
- internal/odoo/ package: types, client stubs, sync stubs
- internal/db/integrations.go: IntegrationRepository
- internal/config/config.go: OdooConfig struct
- 6 API endpoints for config CRUD, sync log, test, push, pull
- All sync operations return stub responses

Documentation:
- docs/REPOSITORY_STATUS.md: comprehensive repository state report
  with architecture overview, API surface, feature stubs, and
  potential issues analysis
This commit is contained in:
Zoe Forbes
2026-01-31 09:20:27 -06:00
parent bce7d5a181
commit 8c0689991e
17 changed files with 2121 additions and 192 deletions

285
docs/REPOSITORY_STATUS.md Normal file
View File

@@ -0,0 +1,285 @@
# Repository Status Report
**Generated:** 2026-01-31
**Branch:** main
**Last Build:** `go build ./...` and `go vet ./...` pass clean
---
## Codebase Summary
| Category | Lines | Files |
|----------|-------|-------|
| Go source | ~6,644 | 20 |
| HTML templates | ~4,923 | 4 |
| Python (FreeCAD) | ~2,499 | 7 |
| SQL migrations | ~464 | 8 |
| **Total** | **~14,730** | **39** |
---
## Architecture
```
cmd/
silo/ CLI client (313 lines)
silod/ API server (126 lines)
internal/
api/ HTTP handlers, routes, middleware, templates (3,491 Go + 4,923 HTML)
config/ YAML config loading (132 lines)
db/ PostgreSQL repositories (1,634 lines)
migration/ Property migration framework (211 lines)
odoo/ Odoo ERP integration stubs (201 lines)
partnum/ Part number generator (180 lines)
schema/ YAML schema parser (235 lines)
storage/ MinIO S3 client (121 lines)
pkg/freecad/ FreeCAD workbench plugin (2,499 Python)
migrations/ Database DDL (8 files)
```
### Key Dependencies
- `go-chi/chi/v5` -- HTTP router
- `jackc/pgx/v5` -- PostgreSQL driver
- `minio/minio-go/v7` -- S3-compatible storage
- `rs/zerolog` -- Structured logging
- `sahilm/fuzzy` -- Fuzzy text matching
- `gopkg.in/yaml.v3` -- YAML parsing
---
## API Surface (38 Routes)
### Web UI
| Method | Path | Handler |
|--------|------|---------|
| GET | `/` | Items page |
| GET | `/projects` | Projects page |
| GET | `/schemas` | Schemas page |
### Items
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items` | List with filtering and pagination |
| POST | `/api/items` | Create item |
| GET | `/api/items/search` | Fuzzy search |
| GET | `/api/items/export.csv` | CSV export |
| POST | `/api/items/import` | CSV import |
| GET | `/api/items/template.csv` | CSV template |
| GET | `/api/items/{partNumber}` | Get item |
| PUT | `/api/items/{partNumber}` | Update item |
| DELETE | `/api/items/{partNumber}` | Archive item |
### Revisions
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/revisions` | List revisions |
| POST | `/api/items/{pn}/revisions` | Create revision |
| GET | `/api/items/{pn}/revisions/compare` | Compare two revisions |
| GET | `/api/items/{pn}/revisions/{rev}` | Get revision |
| PATCH | `/api/items/{pn}/revisions/{rev}` | Update status/label |
| POST | `/api/items/{pn}/revisions/{rev}/rollback` | Rollback |
### Files
| Method | Path | Description |
|--------|------|-------------|
| POST | `/api/items/{pn}/file` | Upload file |
| GET | `/api/items/{pn}/file` | Download latest |
| GET | `/api/items/{pn}/file/{rev}` | Download at revision |
### BOM
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/bom` | List children |
| POST | `/api/items/{pn}/bom` | Add child |
| GET | `/api/items/{pn}/bom/expanded` | Multi-level BOM |
| GET | `/api/items/{pn}/bom/where-used` | Where-used lookup |
| GET | `/api/items/{pn}/bom/export.csv` | BOM CSV export |
| POST | `/api/items/{pn}/bom/import` | BOM CSV import |
| PUT | `/api/items/{pn}/bom/{child}` | Update quantity/ref |
| DELETE | `/api/items/{pn}/bom/{child}` | Remove child |
### Projects
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/projects` | List projects |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/{code}` | Get project |
| PUT | `/api/projects/{code}` | Update project |
| DELETE | `/api/projects/{code}` | Delete project |
| GET | `/api/projects/{code}/items` | Items in project |
### Item-Project Associations
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/items/{pn}/projects` | Item's projects |
| POST | `/api/items/{pn}/projects` | Tag item to project |
| DELETE | `/api/items/{pn}/projects/{code}` | Remove tag |
### Schemas
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/schemas` | List schemas |
| GET | `/api/schemas/{name}` | Get schema |
| GET | `/api/schemas/{name}/properties` | Property definitions |
| POST | `/api/schemas/{name}/segments/{seg}/values` | Add enum value |
| PUT | `/api/schemas/{name}/segments/{seg}/values/{code}` | Update enum value |
| DELETE | `/api/schemas/{name}/segments/{seg}/values/{code}` | Delete enum value |
### Odoo Integration (Stubs)
| Method | Path | Description |
|--------|------|-------------|
| GET | `/api/integrations/odoo/config` | Get config |
| PUT | `/api/integrations/odoo/config` | Update config |
| GET | `/api/integrations/odoo/sync-log` | Sync history |
| POST | `/api/integrations/odoo/test-connection` | Test connection |
| POST | `/api/integrations/odoo/sync/push/{pn}` | Push to Odoo |
| POST | `/api/integrations/odoo/sync/pull/{id}` | Pull from Odoo |
### Other
| Method | Path | Description |
|--------|------|-------------|
| GET | `/health` | Health probe |
| GET | `/ready` | Readiness probe (DB + MinIO) |
| POST | `/api/generate-part-number` | Generate next PN |
---
## Database Migrations
| # | File | Purpose |
|---|------|---------|
| 1 | `001_initial.sql` | Core schema: items, revisions, relationships, locations, inventory, sequences, projects, schemas |
| 2 | `002_sequence_by_name.sql` | Sequence naming changes |
| 3 | `003_remove_material.sql` | Schema cleanup |
| 4 | `004_cad_sync_state.sql` | CAD synchronization tracking |
| 5 | `005_property_schema_version.sql` | Property versioning framework |
| 6 | `006_project_tags.sql` | Many-to-many project-item relationships |
| 7 | `007_revision_status.sql` | Revision status and labels |
| 8 | `008_odoo_integration.sql` | Integrations + sync_log tables |
---
## Web UI Architecture
The web UI uses server-rendered Go templates with vanilla JavaScript (no framework).
### Items Page (`items.html`, 3718 lines)
Infor CloudSuite-style split-panel layout:
- **Horizontal mode** (default): item list on left, tabbed detail panel on right
- **Vertical mode**: tabbed detail panel on top, item list below
- **Detail tabs**: Main, Properties, Revisions, BOM, Where Used
- **Ctrl+F** opens in-page filter overlay with fuzzy search
- **Column config**: gear icon popover, separate settings per layout mode
- **Search scope**: All / Part Number / Description toggle pills
### Projects Page (`projects.html`, 345 lines)
- Full CRUD for project codes
- Item count per project
- Click project code to filter items page
### Schemas Page (`schemas.html`, 689 lines)
- Schema browsing and enum value management
### Base Template (`base.html`, 171 lines)
- 3-tab navigation: Items, Projects, Schemas
- Catppuccin Mocha dark theme
---
## Feature Stubs (Not Yet Implemented)
These are scaffolded but contain placeholder implementations.
### Odoo ERP Integration
All functions in `internal/odoo/` return "not yet implemented" errors:
| File | Function | Status |
|------|----------|--------|
| `client.go:30` | `Authenticate()` | Stub |
| `client.go:41` | `SearchRead()` | Stub |
| `client.go:51` | `Create()` | Stub |
| `client.go:60` | `Write()` | Stub |
| `client.go:70` | `TestConnection()` | Stub |
| `sync.go:27` | `PushItem()` | Stub (logs, returns nil) |
| `sync.go:36` | `PullProduct()` | Stub (logs, returns nil) |
API handlers at `odoo_handlers.go`:
- `HandleTestOdooConnection` (line 134) -- returns stub message
- `HandleOdooPush` (line 149) -- returns stub message
- `HandleOdooPull` (line 167) -- returns stub message
Config and sync-log CRUD handlers are functional.
### Part Number Date Segments
`internal/partnum/generator.go:102` -- `formatDate()` returns error. Date-based segments in schemas will fail at generation time.
### Location and Inventory APIs
Tables exist from migration 001 (`locations`, `inventory`) but no API handlers or repository methods are implemented. The database layer has no `LocationRepository` or `InventoryRepository`.
---
## Potential Issues
### Critical
1. **No authentication or authorization.** All API endpoints are publicly accessible. Single-user only. Adding LDAP/FreeIPA integration is required before multi-user deployment.
2. **No file locking.** Concurrent edits to the same item or file upload can cause data races. A pessimistic locking mechanism is needed for CAD file workflows.
3. **No unit tests.** Zero test coverage across the entire Go codebase. Regressions cannot be caught automatically.
### High
4. **Large monolithic template.** `items.html` is 3,718 lines with inline CSS and JavaScript. Changes risk unintended side effects. Consider extracting JavaScript into separate files or adopting a build step.
5. **No input validation middleware.** API handlers validate some fields inline but there is no systematic validation layer. Malformed requests may produce unclear errors or unexpected behavior.
6. **No rate limiting.** API has no request rate controls. A misbehaving client or script could overwhelm the server.
7. **Odoo handlers reference DB repositories not wired up.** `odoo_handlers.go` calls `s.db.IntegrationRepository()` but the `DB` struct in `db/db.go` does not expose an `IntegrationRepository` method. These handlers will panic if reached with a real database operation. Currently safe because config is stored in-memory and stubs short-circuit before DB calls.
### Medium
8. **No pagination on fuzzy search.** `HandleFuzzySearch` loads all items matching type/project filters into memory before fuzzy matching. Large datasets will cause high memory usage.
9. **CSV import lacks transaction rollback on partial failure.** If import fails mid-batch, already-imported items remain.
10. **No CSRF protection.** Web UI forms submit via `fetch()` but there are no CSRF tokens. Acceptable for single-user but a risk if authentication is added.
11. **MinIO connection not validated at startup.** The `/ready` endpoint checks MinIO, but the server starts regardless. A misconfigured MinIO will only fail on file operations.
12. **Property migration framework exists but has no registered migrations.** `internal/migration/properties.go` defines the framework but no concrete migrations use it yet.
---
## Recent Git History
```
8e44ed2 2026-01-29 Fix SIGSEGV: defer document open after dialog close
e2b3f12 2026-01-29 Fix API URL: only auto-append /api for bare hostnames
559f615 2026-01-29 Fix API URL handling and SSL certificate verification
f08ecc1 2026-01-29 feat(workbench): fix icon loading and add settings dialog
53b5edb 2026-01-29 update documentation and specs
5ee88a6 2026-01-26 update deploy.sh
93add05 2026-01-26 improve csv import handling
2d44b2a 2026-01-26 add free-ipa setup
```
---
## Deployment
### Supported Methods
- **Docker Compose** -- `deployments/docker-compose.yaml` (dev), `docker-compose.prod.yaml` (prod)
- **systemd** -- `deployments/systemd/silod.service`
- **Manual** -- `go build ./cmd/silod && ./silod -config config.yaml`
### Infrastructure Requirements
- PostgreSQL 15+ with `pg_trgm` and `uuid-ossp` extensions
- MinIO or S3-compatible storage
- Go 1.23+ for building

1
go.mod
View File

@@ -29,6 +29,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/rs/xid v1.5.0 // indirect github.com/rs/xid v1.5.0 // indirect
github.com/sahilm/fuzzy v0.1.1 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect
golang.org/x/crypto v0.16.0 // indirect golang.org/x/crypto v0.16.0 // indirect
golang.org/x/net v0.19.0 // indirect golang.org/x/net v0.19.0 // indirect

2
go.sum
View File

@@ -57,6 +57,8 @@ github.com/rs/xid v1.5.0 h1:mKX4bl4iPYJtEIxp6CYiUuLQ/8DYMoz0PUdtGgMFRVc=
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0= github.com/rs/zerolog v1.32.0 h1:keLypqrlIjaFsbmJOBdB/qvyF8KEtCWHwobLp5l/mQ0=
github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss= github.com/rs/zerolog v1.32.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/sahilm/fuzzy v0.1.1 h1:ceu5RHF8DGgoi+/dR5PsECjCDH1BE3Fnmpo7aVXOdRA=
github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"strings"
"github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db" "github.com/kindredsystems/silo/internal/db"
@@ -263,6 +264,52 @@ func (s *Server) HandleListItems(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, response) writeJSON(w, http.StatusOK, response)
} }
// HandleFuzzySearch performs fuzzy search across items.
func (s *Server) HandleFuzzySearch(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
q := r.URL.Query().Get("q")
if q == "" {
writeJSON(w, http.StatusOK, []FuzzyResult{})
return
}
fieldsParam := r.URL.Query().Get("fields")
var fields []string
if fieldsParam != "" {
fields = strings.Split(fieldsParam, ",")
}
limit := 50
if l := r.URL.Query().Get("limit"); l != "" {
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
limit = parsed
}
}
// Pre-filter by type and project via SQL (no search term)
opts := db.ListOptions{
ItemType: r.URL.Query().Get("type"),
Project: r.URL.Query().Get("project"),
Limit: 500, // reasonable upper bound for fuzzy matching
}
items, err := s.items.List(ctx, opts)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list items for fuzzy search")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to search items")
return
}
responses := make([]ItemResponse, len(items))
for i, item := range items {
responses[i] = itemToResponse(item)
}
results := FuzzySearch(q, responses, fields, limit)
writeJSON(w, http.StatusOK, results)
}
// HandleCreateItem creates a new item with generated part number. // HandleCreateItem creates a new item with generated part number.
func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) { func (s *Server) HandleCreateItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context() ctx := r.Context()

View File

@@ -0,0 +1,185 @@
package api
import (
"encoding/json"
"net/http"
"strings"
"github.com/go-chi/chi/v5"
"github.com/kindredsystems/silo/internal/db"
)
// OdooConfigResponse represents the Odoo configuration (API key masked).
type OdooConfigResponse struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
Database string `json:"database"`
Username string `json:"username"`
APIKey string `json:"api_key"`
}
// OdooConfigRequest represents a request to update Odoo configuration.
type OdooConfigRequest struct {
Enabled bool `json:"enabled"`
URL string `json:"url"`
Database string `json:"database"`
Username string `json:"username"`
APIKey string `json:"api_key,omitempty"`
}
// HandleGetOdooConfig returns the current Odoo integration configuration.
func (s *Server) HandleGetOdooConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
repo := db.NewIntegrationRepository(s.db)
integration, err := repo.GetByName(ctx, "odoo")
if err != nil {
s.logger.Error().Err(err).Msg("failed to get odoo config")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get Odoo config")
return
}
if integration == nil {
writeJSON(w, http.StatusOK, OdooConfigResponse{})
return
}
resp := OdooConfigResponse{
Enabled: integration.Enabled,
URL: getString(integration.Config, "url"),
Database: getString(integration.Config, "database"),
Username: getString(integration.Config, "username"),
}
// Mask API key
apiKey := getString(integration.Config, "api_key")
if len(apiKey) > 4 {
resp.APIKey = strings.Repeat("*", len(apiKey)-4) + apiKey[len(apiKey)-4:]
} else if apiKey != "" {
resp.APIKey = "****"
}
writeJSON(w, http.StatusOK, resp)
}
// HandleUpdateOdooConfig updates the Odoo integration configuration.
func (s *Server) HandleUpdateOdooConfig(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var req OdooConfigRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid_json", err.Error())
return
}
repo := db.NewIntegrationRepository(s.db)
// If API key is masked or empty, preserve existing one
if req.APIKey == "" || strings.Contains(req.APIKey, "*") {
existing, err := repo.GetByName(ctx, "odoo")
if err == nil && existing != nil {
req.APIKey = getString(existing.Config, "api_key")
}
}
config := map[string]any{
"url": req.URL,
"database": req.Database,
"username": req.Username,
"api_key": req.APIKey,
}
if err := repo.Upsert(ctx, "odoo", req.Enabled, config); err != nil {
s.logger.Error().Err(err).Msg("failed to update odoo config")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to update Odoo config")
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// HandleGetOdooSyncLog returns recent sync log entries for the Odoo integration.
func (s *Server) HandleGetOdooSyncLog(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
repo := db.NewIntegrationRepository(s.db)
integration, err := repo.GetByName(ctx, "odoo")
if err != nil {
s.logger.Error().Err(err).Msg("failed to get odoo integration")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to get sync log")
return
}
if integration == nil {
writeJSON(w, http.StatusOK, []any{})
return
}
logs, err := repo.ListSyncLog(ctx, integration.ID, 50)
if err != nil {
s.logger.Error().Err(err).Msg("failed to list sync log")
writeError(w, http.StatusInternalServerError, "internal_error", "Failed to list sync log")
return
}
if logs == nil {
logs = []*db.SyncLog{}
}
writeJSON(w, http.StatusOK, logs)
}
// HandleTestOdooConnection tests the Odoo connection.
func (s *Server) HandleTestOdooConnection(w http.ResponseWriter, r *http.Request) {
// Stub - would use odoo.Client to test connection
writeJSON(w, http.StatusOK, map[string]any{
"status": "not_implemented",
"message": "Odoo connection testing is not yet implemented",
})
}
// HandleOdooPush pushes an item to Odoo.
func (s *Server) HandleOdooPush(w http.ResponseWriter, r *http.Request) {
partNumber := chi.URLParam(r, "partNumber")
if partNumber == "" {
writeError(w, http.StatusBadRequest, "missing_param", "Part number is required")
return
}
// Stub - log the intent
s.logger.Info().Str("part_number", partNumber).Msg("odoo push requested (stub)")
writeJSON(w, http.StatusOK, map[string]any{
"status": "pending",
"message": "Odoo push is not yet implemented",
"part_number": partNumber,
})
}
// HandleOdooPull pulls a product from Odoo.
func (s *Server) HandleOdooPull(w http.ResponseWriter, r *http.Request) {
odooID := chi.URLParam(r, "odooId")
if odooID == "" {
writeError(w, http.StatusBadRequest, "missing_param", "Odoo ID is required")
return
}
// Stub - log the intent
s.logger.Info().Str("odoo_id", odooID).Msg("odoo pull requested (stub)")
writeJSON(w, http.StatusOK, map[string]any{
"status": "pending",
"message": "Odoo pull is not yet implemented",
"odoo_id": odooID,
})
}
// getString safely extracts a string from a map.
func getString(m map[string]any, key string) string {
if v, ok := m[key]; ok {
if s, ok := v.(string); ok {
return s
}
}
return ""
}

View File

@@ -39,6 +39,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Web UI routes // Web UI routes
r.Get("/", webHandler.HandleIndex) r.Get("/", webHandler.HandleIndex)
r.Get("/projects", webHandler.HandleProjectsPage)
r.Get("/schemas", webHandler.HandleSchemasPage) r.Get("/schemas", webHandler.HandleSchemasPage)
// API routes // API routes
@@ -70,6 +71,7 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
// Items // Items
r.Route("/items", func(r chi.Router) { r.Route("/items", func(r chi.Router) {
r.Get("/", server.HandleListItems) r.Get("/", server.HandleListItems)
r.Get("/search", server.HandleFuzzySearch)
r.Post("/", server.HandleCreateItem) r.Post("/", server.HandleCreateItem)
// CSV Import/Export // CSV Import/Export
@@ -112,6 +114,16 @@ func NewRouter(server *Server, logger zerolog.Logger) http.Handler {
}) })
}) })
// Integrations
r.Route("/integrations/odoo", func(r chi.Router) {
r.Get("/config", server.HandleGetOdooConfig)
r.Put("/config", server.HandleUpdateOdooConfig)
r.Get("/sync-log", server.HandleGetOdooSyncLog)
r.Post("/test-connection", server.HandleTestOdooConnection)
r.Post("/sync/push/{partNumber}", server.HandleOdooPush)
r.Post("/sync/pull/{odooId}", server.HandleOdooPull)
})
// Part number generation // Part number generation
r.Post("/generate-part-number", server.HandleGeneratePartNumber) r.Post("/generate-part-number", server.HandleGeneratePartNumber)
}) })

72
internal/api/search.go Normal file
View File

@@ -0,0 +1,72 @@
package api
import (
"strings"
"github.com/sahilm/fuzzy"
)
// SearchableItems implements the fuzzy.Source interface for item search.
type SearchableItems struct {
items []ItemResponse
fields []string
}
// String returns the searchable text for item at index i.
func (s SearchableItems) String(i int) string {
item := s.items[i]
var parts []string
for _, field := range s.fields {
switch field {
case "part_number":
parts = append(parts, item.PartNumber)
case "description":
parts = append(parts, item.Description)
case "item_type":
parts = append(parts, item.ItemType)
}
}
if len(parts) == 0 {
// Default: search all text fields
parts = append(parts, item.PartNumber, item.Description, item.ItemType)
}
return strings.Join(parts, " ")
}
// Len returns the number of items.
func (s SearchableItems) Len() int {
return len(s.items)
}
// FuzzyResult wraps an ItemResponse with match score.
type FuzzyResult struct {
ItemResponse
Score int `json:"score"`
}
// FuzzySearch runs fuzzy matching on items and returns ranked results.
func FuzzySearch(pattern string, items []ItemResponse, fields []string, limit int) []FuzzyResult {
if pattern == "" || len(items) == 0 {
return nil
}
source := SearchableItems{items: items, fields: fields}
matches := fuzzy.FindFrom(pattern, source)
results := make([]FuzzyResult, 0, len(matches))
for _, m := range matches {
results = append(results, FuzzyResult{
ItemResponse: items[m.Index],
Score: m.Score,
})
}
if limit > 0 && len(results) > limit {
results = results[:limit]
}
return results
}

View File

@@ -480,6 +480,7 @@
</div> </div>
<nav class="header-nav"> <nav class="header-nav">
<a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a> <a href="/" class="{{if eq .Page "items"}}active{{end}}">Items</a>
<a href="/projects" class="{{if eq .Page "projects"}}active{{end}}">Projects</a>
<a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a> <a href="/schemas" class="{{if eq .Page "schemas"}}active{{end}}">Schemas</a>
</nav> </nav>
</header> </header>
@@ -487,6 +488,8 @@
<main class="main"> <main class="main">
{{if eq .Page "items"}} {{if eq .Page "items"}}
{{template "items_content" .}} {{template "items_content" .}}
{{else if eq .Page "projects"}}
{{template "projects_content" .}}
{{else if eq .Page "schemas"}} {{else if eq .Page "schemas"}}
{{template "schemas_content" .}} {{template "schemas_content" .}}
{{end}} {{end}}
@@ -494,6 +497,8 @@
{{if eq .Page "items"}} {{if eq .Page "items"}}
{{template "items_scripts" .}} {{template "items_scripts" .}}
{{else if eq .Page "projects"}}
{{template "projects_scripts" .}}
{{else if eq .Page "schemas"}} {{else if eq .Page "schemas"}}
{{template "schemas_scripts" .}} {{template "schemas_scripts" .}}
{{end}} {{end}}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,300 @@
{{define "projects_content"}}
<div class="stats-grid" id="project-stats">
<div class="stat-card">
<div class="stat-value" id="project-count">-</div>
<div class="stat-label">Total Projects</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h2 class="card-title">Projects</h2>
<button class="btn btn-primary" onclick="openCreateProjectModal()">+ New Project</button>
</div>
<div class="table-container">
<table>
<thead>
<tr>
<th>Code</th>
<th>Name</th>
<th>Description</th>
<th>Items</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="projects-table">
<tr>
<td colspan="6">
<div class="loading"><div class="spinner"></div></div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create Project Modal -->
<div class="modal-overlay" id="create-project-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Create New Project</h3>
<button class="modal-close" onclick="closeCreateProjectModal()">&times;</button>
</div>
<form id="create-project-form" onsubmit="createProject(event)">
<div class="form-group">
<label class="form-label">Code (2-10 characters, uppercase)</label>
<input type="text" class="form-input" id="project-code" required
minlength="2" maxlength="10" pattern="[A-Za-z0-9\-]+"
placeholder="e.g., PROJ-A" style="text-transform: uppercase;" />
</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="project-name" placeholder="Project name" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" class="form-input" id="project-description" placeholder="Project description" />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeCreateProjectModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Create Project</button>
</div>
</form>
</div>
</div>
<!-- Edit Project Modal -->
<div class="modal-overlay" id="edit-project-modal">
<div class="modal">
<div class="modal-header">
<h3 class="modal-title">Edit Project</h3>
<button class="modal-close" onclick="closeEditProjectModal()">&times;</button>
</div>
<form id="edit-project-form" onsubmit="saveProject(event)">
<input type="hidden" id="edit-project-code" />
<div class="form-group">
<label class="form-label">Code</label>
<input type="text" class="form-input" id="edit-project-code-display" disabled />
</div>
<div class="form-group">
<label class="form-label">Name</label>
<input type="text" class="form-input" id="edit-project-name" placeholder="Project name" />
</div>
<div class="form-group">
<label class="form-label">Description</label>
<input type="text" class="form-input" id="edit-project-description" placeholder="Project description" />
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeEditProjectModal()">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
<!-- Delete Project Modal -->
<div class="modal-overlay" id="delete-project-modal">
<div class="modal" style="max-width: 400px">
<div class="modal-header">
<h3 class="modal-title">Delete Project</h3>
<button class="modal-close" onclick="closeDeleteProjectModal()">&times;</button>
</div>
<div style="margin-bottom: 1.5rem;">
<p>Are you sure you want to permanently delete project <strong id="delete-project-code"></strong>?</p>
<p style="color: var(--ctp-red); margin-top: 0.5rem;">This action cannot be undone.</p>
</div>
<div class="form-actions">
<button type="button" class="btn btn-secondary" onclick="closeDeleteProjectModal()">Cancel</button>
<button type="button" class="btn btn-primary" style="background-color: var(--ctp-red)" onclick="confirmDeleteProject()">Delete</button>
</div>
</div>
</div>
{{end}} {{define "projects_scripts"}}
<script>
let projectToDelete = null;
function formatDate(dateStr) {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
async function loadProjects() {
const tbody = document.getElementById('projects-table');
try {
const response = await fetch('/api/projects');
const projects = await response.json();
document.getElementById('project-count').textContent = projects.length;
if (!projects || projects.length === 0) {
tbody.innerHTML = '<tr><td colspan="6"><div class="empty-state"><h3>No projects found</h3><p>Create your first project to start organizing items.</p></div></td></tr>';
return;
}
// Fetch item counts for each project
const projectsWithCounts = await Promise.all(projects.map(async (project) => {
try {
const itemsRes = await fetch(`/api/projects/${project.code}/items`);
const items = await itemsRes.json();
return { ...project, itemCount: Array.isArray(items) ? items.length : 0 };
} catch {
return { ...project, itemCount: 0 };
}
}));
tbody.innerHTML = projectsWithCounts.map(project => `
<tr>
<td><a href="/?project=${encodeURIComponent(project.code)}" style="color: var(--ctp-peach); font-family: 'JetBrains Mono', monospace; font-weight: 500;">${project.code}</a></td>
<td>${project.name || '-'}</td>
<td>${project.description || '-'}</td>
<td>${project.itemCount}</td>
<td>${formatDate(project.created_at)}</td>
<td>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem;" onclick="openEditProjectModal('${project.code}')">Edit</button>
<button class="btn btn-secondary" style="padding: 0.4rem 0.75rem; font-size: 0.85rem; background-color: var(--ctp-surface2);" onclick="openDeleteProjectModal('${project.code}')">Delete</button>
</td>
</tr>
`).join('');
} catch (error) {
tbody.innerHTML = `<tr><td colspan="6"><div class="empty-state"><h3>Error loading projects</h3><p>${error.message}</p></div></td></tr>`;
}
}
// Create
function openCreateProjectModal() {
document.getElementById('create-project-modal').classList.add('active');
}
function closeCreateProjectModal() {
document.getElementById('create-project-modal').classList.remove('active');
document.getElementById('create-project-form').reset();
}
async function createProject(event) {
event.preventDefault();
const data = {
code: document.getElementById('project-code').value.toUpperCase(),
name: document.getElementById('project-name').value,
description: document.getElementById('project-description').value,
};
try {
const response = await fetch('/api/projects', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeCreateProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Edit
async function openEditProjectModal(code) {
document.getElementById('edit-project-modal').classList.add('active');
try {
const response = await fetch(`/api/projects/${code}`);
const project = await response.json();
document.getElementById('edit-project-code').value = code;
document.getElementById('edit-project-code-display').value = code;
document.getElementById('edit-project-name').value = project.name || '';
document.getElementById('edit-project-description').value = project.description || '';
} catch (error) {
alert(`Error loading project: ${error.message}`);
closeEditProjectModal();
}
}
function closeEditProjectModal() {
document.getElementById('edit-project-modal').classList.remove('active');
document.getElementById('edit-project-form').reset();
}
async function saveProject(event) {
event.preventDefault();
const code = document.getElementById('edit-project-code').value;
const data = {
name: document.getElementById('edit-project-name').value,
description: document.getElementById('edit-project-description').value,
};
try {
const response = await fetch(`/api/projects/${code}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeEditProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Delete
function openDeleteProjectModal(code) {
projectToDelete = code;
document.getElementById('delete-project-code').textContent = code;
document.getElementById('delete-project-modal').classList.add('active');
}
function closeDeleteProjectModal() {
document.getElementById('delete-project-modal').classList.remove('active');
projectToDelete = null;
}
async function confirmDeleteProject() {
if (!projectToDelete) return;
try {
const response = await fetch(`/api/projects/${projectToDelete}`, {
method: 'DELETE',
});
if (!response.ok && response.status !== 204) {
const error = await response.json();
alert(`Error: ${error.message || error.error}`);
return;
}
closeDeleteProjectModal();
loadProjects();
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Close modals on overlay click
document.querySelectorAll('.modal-overlay').forEach(overlay => {
overlay.addEventListener('click', (e) => {
if (e.target === overlay) overlay.classList.remove('active');
});
});
loadProjects();
</script>
{{end}}

View File

@@ -77,6 +77,20 @@ func (h *WebHandler) HandleIndex(w http.ResponseWriter, r *http.Request) {
} }
} }
// HandleProjectsPage serves the projects page.
func (h *WebHandler) HandleProjectsPage(w http.ResponseWriter, r *http.Request) {
data := PageData{
Title: "Projects",
Page: "projects",
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := h.templates.ExecuteTemplate(w, "base.html", data); err != nil {
h.logger.Error().Err(err).Msg("failed to render template")
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
}
}
// HandleSchemasPage serves the schemas page. // HandleSchemasPage serves the schemas page.
func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) { func (h *WebHandler) HandleSchemasPage(w http.ResponseWriter, r *http.Request) {
data := PageData{ data := PageData{

View File

@@ -15,6 +15,7 @@ type Config struct {
Storage StorageConfig `yaml:"storage"` Storage StorageConfig `yaml:"storage"`
Schemas SchemasConfig `yaml:"schemas"` Schemas SchemasConfig `yaml:"schemas"`
FreeCAD FreeCADConfig `yaml:"freecad"` FreeCAD FreeCADConfig `yaml:"freecad"`
Odoo OdooConfig `yaml:"odoo"`
} }
// ServerConfig holds HTTP server settings. // ServerConfig holds HTTP server settings.
@@ -57,6 +58,15 @@ type FreeCADConfig struct {
Executable string `yaml:"executable"` Executable string `yaml:"executable"`
} }
// OdooConfig holds Odoo ERP integration settings.
type OdooConfig struct {
Enabled bool `yaml:"enabled"`
URL string `yaml:"url"`
Database string `yaml:"database"`
Username string `yaml:"username"`
APIKey string `yaml:"api_key"`
}
// Load reads configuration from a YAML file. // Load reads configuration from a YAML file.
func Load(path string) (*Config, error) { func Load(path string) (*Config, error) {
data, err := os.ReadFile(path) data, err := os.ReadFile(path)

139
internal/db/integrations.go Normal file
View File

@@ -0,0 +1,139 @@
package db
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5"
)
// Integration represents an ERP integration configuration.
type Integration struct {
ID string
Name string
Enabled bool
Config map[string]any
CreatedAt time.Time
UpdatedAt time.Time
}
// SyncLog represents a sync log entry.
type SyncLog struct {
ID string
IntegrationID string
ItemID *string
Direction string
Status string
ExternalID string
ExternalModel string
RequestPayload json.RawMessage
ResponsePayload json.RawMessage
ErrorMessage string
StartedAt *time.Time
CompletedAt *time.Time
CreatedAt time.Time
}
// IntegrationRepository provides integration database operations.
type IntegrationRepository struct {
db *DB
}
// NewIntegrationRepository creates a new integration repository.
func NewIntegrationRepository(db *DB) *IntegrationRepository {
return &IntegrationRepository{db: db}
}
// GetByName returns an integration by name.
func (r *IntegrationRepository) GetByName(ctx context.Context, name string) (*Integration, error) {
row := r.db.pool.QueryRow(ctx, `
SELECT id, name, enabled, config, created_at, updated_at
FROM integrations
WHERE name = $1
`, name)
var i Integration
var configJSON []byte
err := row.Scan(&i.ID, &i.Name, &i.Enabled, &configJSON, &i.CreatedAt, &i.UpdatedAt)
if err == pgx.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
if len(configJSON) > 0 {
if err := json.Unmarshal(configJSON, &i.Config); err != nil {
return nil, err
}
}
return &i, nil
}
// Upsert creates or updates an integration by name.
func (r *IntegrationRepository) Upsert(ctx context.Context, name string, enabled bool, config map[string]any) error {
configJSON, err := json.Marshal(config)
if err != nil {
return err
}
_, err = r.db.pool.Exec(ctx, `
INSERT INTO integrations (name, enabled, config)
VALUES ($1, $2, $3)
ON CONFLICT (name) DO UPDATE SET
enabled = EXCLUDED.enabled,
config = EXCLUDED.config,
updated_at = now()
`, name, enabled, configJSON)
return err
}
// CreateSyncLog inserts a new sync log entry.
func (r *IntegrationRepository) CreateSyncLog(ctx context.Context, entry *SyncLog) error {
_, err := r.db.pool.Exec(ctx, `
INSERT INTO sync_log (integration_id, item_id, direction, status, external_id, external_model, request_payload, response_payload, error_message, started_at, completed_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
`, entry.IntegrationID, entry.ItemID, entry.Direction, entry.Status,
entry.ExternalID, entry.ExternalModel, entry.RequestPayload, entry.ResponsePayload,
entry.ErrorMessage, entry.StartedAt, entry.CompletedAt)
return err
}
// ListSyncLog returns recent sync log entries for an integration.
func (r *IntegrationRepository) ListSyncLog(ctx context.Context, integrationID string, limit int) ([]*SyncLog, error) {
if limit <= 0 {
limit = 50
}
rows, err := r.db.pool.Query(ctx, `
SELECT id, integration_id, item_id, direction, status,
external_id, external_model, request_payload, response_payload,
error_message, started_at, completed_at, created_at
FROM sync_log
WHERE integration_id = $1
ORDER BY created_at DESC
LIMIT $2
`, integrationID, limit)
if err != nil {
return nil, err
}
defer rows.Close()
var logs []*SyncLog
for rows.Next() {
var l SyncLog
err := rows.Scan(
&l.ID, &l.IntegrationID, &l.ItemID, &l.Direction, &l.Status,
&l.ExternalID, &l.ExternalModel, &l.RequestPayload, &l.ResponsePayload,
&l.ErrorMessage, &l.StartedAt, &l.CompletedAt, &l.CreatedAt,
)
if err != nil {
return nil, err
}
logs = append(logs, &l)
}
return logs, rows.Err()
}

75
internal/odoo/client.go Normal file
View File

@@ -0,0 +1,75 @@
package odoo
import (
"context"
"fmt"
"net/http"
"github.com/rs/zerolog"
)
// Client provides access to the Odoo JSON-RPC API.
type Client struct {
config Config
http *http.Client
logger zerolog.Logger
uid int
}
// NewClient creates a new Odoo API client.
func NewClient(cfg Config, logger zerolog.Logger) *Client {
return &Client{
config: cfg,
http: &http.Client{},
logger: logger.With().Str("component", "odoo-client").Logger(),
}
}
// Authenticate authenticates with the Odoo server and stores the user ID.
func (c *Client) Authenticate(_ context.Context) error {
// TODO: Implement JSON-RPC call to /web/session/authenticate
c.logger.Info().
Str("url", c.config.URL).
Str("database", c.config.Database).
Str("username", c.config.Username).
Msg("odoo authenticate stub called")
return fmt.Errorf("odoo authentication not yet implemented")
}
// SearchRead queries records from an Odoo model.
func (c *Client) SearchRead(_ context.Context, model string, domain []any, fields []string, limit int) ([]map[string]any, error) {
// TODO: Implement JSON-RPC call to /web/dataset/call_kw
c.logger.Info().
Str("model", model).
Int("limit", limit).
Msg("odoo search_read stub called")
return nil, fmt.Errorf("odoo search_read not yet implemented")
}
// Create creates a new record in an Odoo model.
func (c *Client) Create(_ context.Context, model string, values map[string]any) (int, error) {
// TODO: Implement JSON-RPC call to create
c.logger.Info().
Str("model", model).
Msg("odoo create stub called")
return 0, fmt.Errorf("odoo create not yet implemented")
}
// Write updates an existing record in an Odoo model.
func (c *Client) Write(_ context.Context, model string, id int, values map[string]any) error {
// TODO: Implement JSON-RPC call to write
c.logger.Info().
Str("model", model).
Int("id", id).
Msg("odoo write stub called")
return fmt.Errorf("odoo write not yet implemented")
}
// TestConnection verifies the Odoo connection is working.
func (c *Client) TestConnection(_ context.Context) error {
// TODO: Attempt authentication and return result
c.logger.Info().
Str("url", c.config.URL).
Msg("odoo test_connection stub called")
return fmt.Errorf("odoo test connection not yet implemented")
}

39
internal/odoo/sync.go Normal file
View File

@@ -0,0 +1,39 @@
package odoo
import (
"context"
"github.com/rs/zerolog"
)
// Syncer orchestrates sync operations between Silo and Odoo.
type Syncer struct {
client *Client
logger zerolog.Logger
}
// NewSyncer creates a new sync orchestrator.
func NewSyncer(client *Client, logger zerolog.Logger) *Syncer {
return &Syncer{
client: client,
logger: logger.With().Str("component", "odoo-syncer").Logger(),
}
}
// PushItem pushes a Silo item to Odoo as a product.
func (s *Syncer) PushItem(_ context.Context, partNumber string) error {
s.logger.Info().
Str("part_number", partNumber).
Msg("push item to odoo stub called")
// TODO: Fetch item from DB, map fields, call client.Create or client.Write
return nil
}
// PullProduct pulls an Odoo product into Silo as an item.
func (s *Syncer) PullProduct(_ context.Context, odooID int) error {
s.logger.Info().
Int("odoo_id", odooID).
Msg("pull product from odoo stub called")
// TODO: Call client.SearchRead, map fields, create/update item in DB
return nil
}

87
internal/odoo/types.go Normal file
View File

@@ -0,0 +1,87 @@
package odoo
import "time"
// Config holds Odoo connection configuration.
type Config struct {
URL string `json:"url" yaml:"url"`
Database string `json:"database" yaml:"database"`
Username string `json:"username" yaml:"username"`
APIKey string `json:"api_key" yaml:"api_key"`
FieldMap map[string]string `json:"field_map,omitempty" yaml:"field_map,omitempty"`
}
// JSONRPCRequest is the Odoo JSON-RPC request envelope.
type JSONRPCRequest struct {
JSONRPC string `json:"jsonrpc"`
Method string `json:"method"`
ID int `json:"id"`
Params any `json:"params"`
}
// JSONRPCResponse is the Odoo JSON-RPC response envelope.
type JSONRPCResponse struct {
JSONRPC string `json:"jsonrpc"`
ID int `json:"id"`
Result any `json:"result,omitempty"`
Error *struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
} `json:"error,omitempty"`
}
// ProductProduct maps to the Odoo product.product model.
type ProductProduct struct {
ID int `json:"id"`
DefaultCode string `json:"default_code"` // maps to part_number
Name string `json:"name"` // maps to description
Type string `json:"type"`
ListPrice float64 `json:"list_price"`
Active bool `json:"active"`
}
// ProductTemplate maps to the Odoo product.template model.
type ProductTemplate struct {
ID int `json:"id"`
DefaultCode string `json:"default_code"`
Name string `json:"name"`
Type string `json:"type"`
ListPrice float64 `json:"list_price"`
Active bool `json:"active"`
}
// SyncDirection indicates the direction of a sync operation.
type SyncDirection string
const (
SyncPush SyncDirection = "push"
SyncPull SyncDirection = "pull"
)
// SyncStatus indicates the status of a sync operation.
type SyncStatus string
const (
SyncPending SyncStatus = "pending"
SyncRunning SyncStatus = "running"
SyncCompleted SyncStatus = "completed"
SyncFailed SyncStatus = "failed"
)
// SyncLogEntry represents a single sync operation record.
type SyncLogEntry struct {
ID string `json:"id"`
IntegrationID string `json:"integration_id"`
ItemID *string `json:"item_id,omitempty"`
Direction SyncDirection `json:"direction"`
Status SyncStatus `json:"status"`
ExternalID string `json:"external_id,omitempty"`
ExternalModel string `json:"external_model,omitempty"`
RequestPayload any `json:"request_payload,omitempty"`
ResponsePayload any `json:"response_payload,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
StartedAt *time.Time `json:"started_at,omitempty"`
CompletedAt *time.Time `json:"completed_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -0,0 +1,30 @@
-- Integration configuration and sync logging for ERP connections (Odoo, etc.)
CREATE TABLE IF NOT EXISTS integrations (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
name TEXT UNIQUE NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT false,
config JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS sync_log (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
integration_id UUID NOT NULL REFERENCES integrations(id) ON DELETE CASCADE,
item_id UUID REFERENCES items(id) ON DELETE SET NULL,
direction TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
external_id TEXT,
external_model TEXT,
request_payload JSONB,
response_payload JSONB,
error_message TEXT,
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sync_log_integration ON sync_log(integration_id);
CREATE INDEX IF NOT EXISTS idx_sync_log_item ON sync_log(item_id);
CREATE INDEX IF NOT EXISTS idx_sync_log_status ON sync_log(status);