diff --git a/CHANGELOG.md b/CHANGELOG.md index be4751d..9133476 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,27 @@ +## Unreleased + +### Added + +- Documentation for Admin UI Media Library (arc42 updates, refined TODO, media DB model decisions per ADR-006). +- Started implementation of full Media CRUD + video duration extraction using ffprobe (Media Library - Full CRUD section). +- Added `/api/admin/media/{id}` GET/PUT/DELETE handlers for media metadata retrieval, updates, and file+record deletion. +- Extended admin schedule and dashboard data endpoints with media duration totals, runtime strings, and media counts. +- Added admin UI runtime display for schedule/dashboard plus media library edit/delete actions. + +### Changed + +- Revised ADR-006 to reflect current implemented storage: PostgreSQL is the compose default and deployed primary backend, with SQLite retained as optional fallback. +- Updated chat runtime image to include ffmpeg/ffprobe for media duration probing on upload. + +### Fixed + +- Started Admin UI Media Library backend implementation by adding the `Media` GORM model and AutoMigrate integration in chat startup. +- Implemented media upload duration probing with ffprobe and persisted media metadata to the database alongside filesystem storage. +- Updated media listing to read from the media table and return duration metadata with HH:MM:SS runtime formatting. + ## [1.0.3] - 2026-08-03 ### Added diff --git a/backend/chat/Dockerfile b/backend/chat/Dockerfile index 8f6c672..8c66c6a 100644 --- a/backend/chat/Dockerfile +++ b/backend/chat/Dockerfile @@ -9,7 +9,7 @@ RUN go mod tidy RUN CGO_ENABLED=1 GOOS=linux go build -o /chat-server ./src/main.go FROM alpine:3.20 -RUN apk --no-cache add ca-certificates curl curl +RUN apk --no-cache add ca-certificates curl ffmpeg WORKDIR /app COPY --from=builder /chat-server /usr/local/bin/chat-server diff --git a/backend/chat/src/main.go b/backend/chat/src/main.go index a422c56..b702e3b 100644 --- a/backend/chat/src/main.go +++ b/backend/chat/src/main.go @@ -10,6 +10,7 @@ import ( "log" "net/http" "os" + "os/exec" "path/filepath" "sort" "strconv" @@ -37,7 +38,7 @@ var ( authSessionsMu = sync.Mutex{} ) -// Message represents a chat message (persisted in SQLite per ADR-006) +// Message represents a chat message (persisted in SQL DB per ADR-006) type Message struct { ID uint `gorm:"primarykey" json:"id"` Room string `json:"room"` @@ -57,6 +58,21 @@ type ModerationLog struct { Timestamp time.Time `json:"timestamp"` } +// Media stores admin media library metadata (filesystem + DB model per ADR-006) +type Media struct { + ID uint `gorm:"primarykey" json:"id"` + Filename string `gorm:"index" json:"filename"` + OriginalName string `json:"originalName"` + Path string `json:"path"` + SizeBytes int64 `json:"sizeBytes"` + DurationSeconds int `json:"durationSeconds"` + Status string `gorm:"index" json:"status"` + Title string `json:"title"` + Tags string `json:"tags"` + UploadedAt time.Time `gorm:"autoCreateTime" json:"uploadedAt"` + UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"` +} + // Presence tracks online users per room (using Redis per ADR-007) type Presence struct { Username string `json:"username"` @@ -68,6 +84,9 @@ type AdminOverview struct { Viewers int `json:"viewers"` StoredMessages int64 `json:"storedMessages"` ModerationActions int64 `json:"moderationActions"` + MediaCount int64 `json:"mediaCount"` + MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"` + MediaTotalRuntime string `json:"mediaTotalRuntime"` RedisEnabled bool `json:"redisEnabled"` RedisClients int `json:"redisClients"` RedisMemoryUsed string `json:"redisMemoryUsed"` @@ -101,6 +120,9 @@ type AdminAnalytics struct { MessagesLast24Hours int64 `json:"messagesLast24Hours"` DistinctRooms int64 `json:"distinctRooms"` ModerationActions int64 `json:"moderationActions"` + MediaCount int64 `json:"mediaCount"` + MediaTotalDurationSeconds int64 `json:"mediaTotalDurationSeconds"` + MediaTotalRuntime string `json:"mediaTotalRuntime"` TopRooms []RoomAnalytics `json:"topRooms"` GeneratedAt string `json:"generatedAt"` } @@ -135,20 +157,37 @@ type AdminUsersResponse struct { } type ScheduleEntry struct { - Index int `json:"index"` - Path string `json:"path"` - Label string `json:"label"` + Index int `json:"index"` + Path string `json:"path"` + Label string `json:"label"` + DurationSeconds int `json:"durationSeconds"` + Runtime string `json:"runtime"` + Status string `json:"status"` + Title string `json:"title"` } type AdminScheduleResponse struct { - Entries []ScheduleEntry `json:"entries"` - GeneratedAt string `json:"generatedAt"` + Entries []ScheduleEntry `json:"entries"` + TotalEntries int `json:"totalEntries"` + MatchedEntries int `json:"matchedEntries"` + TotalDurationSeconds int `json:"totalDurationSeconds"` + TotalRuntime string `json:"totalRuntime"` + GeneratedAt string `json:"generatedAt"` } type MediaFileEntry struct { - Name string `json:"name"` - Size int64 `json:"size"` - ModifiedAt string `json:"modifiedAt"` + ID uint `json:"id"` + Name string `json:"name"` + OriginalName string `json:"originalName"` + Path string `json:"path"` + Size int64 `json:"size"` + DurationSeconds int `json:"durationSeconds"` + Runtime string `json:"runtime"` + Status string `json:"status"` + Title string `json:"title"` + Tags string `json:"tags"` + UploadedAt string `json:"uploadedAt"` + ModifiedAt string `json:"modifiedAt"` } type AdminMediaResponse struct { @@ -199,7 +238,7 @@ func initRedis() { } } -// initDB initializes SQLite for message history and moderation audit log (ADR-006) +// initDB initializes configured SQL storage for chat, moderation, and admin metadata (ADR-006) func initDB() { var err error databaseURL := strings.TrimSpace(os.Getenv("DATABASE_URL")) @@ -226,8 +265,8 @@ func initDB() { } databaseDriverName = driverName - db.AutoMigrate(&Message{}, &ModerationLog{}) - log.Printf("%s database initialized (chat history and moderation log)", strings.Title(driverName)) + db.AutoMigrate(&Message{}, &ModerationLog{}, &Media{}) + log.Printf("%s database initialized (chat history, moderation log, media metadata)", strings.Title(driverName)) } func parseRedisInfoValue(info, key string) string { @@ -246,6 +285,7 @@ func getAdminOverview() AdminOverview { RedisEnabled: redisClient != nil, RedisMemoryUsed: "unavailable", DatabaseDriver: databaseDriverName, + MediaTotalRuntime: "00:00:00", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } @@ -256,6 +296,9 @@ func getAdminOverview() AdminOverview { if db != nil { db.Model(&Message{}).Count(&overview.StoredMessages) db.Model(&ModerationLog{}).Count(&overview.ModerationActions) + db.Model(&Media{}).Count(&overview.MediaCount) + db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&overview.MediaTotalDurationSeconds) + overview.MediaTotalRuntime = formatDuration(int(overview.MediaTotalDurationSeconds)) } if redisClient != nil { @@ -541,10 +584,55 @@ func handleAdminSchedule(w http.ResponseWriter, r *http.Request) { return } + entries := parsePlaylistEntries(string(content)) + + var mediaRecords []Media + if err := db.Find(&mediaRecords).Error; err != nil { + http.Error(w, fmt.Sprintf("failed to load media metadata: %v", err), http.StatusInternalServerError) + return + } + + mediaByFilename := make(map[string]Media, len(mediaRecords)) + mediaByPath := make(map[string]Media, len(mediaRecords)) + for _, record := range mediaRecords { + mediaByFilename[strings.ToLower(record.Filename)] = record + mediaByPath[strings.ToLower(filepath.Clean(record.Path))] = record + } + + totalDurationSeconds := 0 + matchedEntries := 0 + for i := range entries { + entry := &entries[i] + entry.Status = "missing" + entry.Runtime = "00:00:00" + + cleanPath := strings.ToLower(filepath.Clean(entry.Path)) + baseName := strings.ToLower(filepath.Base(entry.Path)) + + record, ok := mediaByPath[cleanPath] + if !ok { + record, ok = mediaByFilename[baseName] + } + if !ok { + continue + } + + entry.DurationSeconds = record.DurationSeconds + entry.Runtime = formatDuration(record.DurationSeconds) + entry.Status = record.Status + entry.Title = record.Title + totalDurationSeconds += record.DurationSeconds + matchedEntries++ + } + w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(AdminScheduleResponse{ - Entries: parsePlaylistEntries(string(content)), - GeneratedAt: time.Now().UTC().Format(time.RFC3339), + Entries: entries, + TotalEntries: len(entries), + MatchedEntries: matchedEntries, + TotalDurationSeconds: totalDurationSeconds, + TotalRuntime: formatDuration(totalDurationSeconds), + GeneratedAt: time.Now().UTC().Format(time.RFC3339), }) } @@ -624,6 +712,7 @@ func handleAdminModerationAction(w http.ResponseWriter, r *http.Request) { func getAdminAnalytics() AdminAnalytics { analytics := AdminAnalytics{ TopRooms: []RoomAnalytics{}, + MediaTotalRuntime: "00:00:00", GeneratedAt: time.Now().UTC().Format(time.RFC3339), } @@ -637,6 +726,9 @@ func getAdminAnalytics() AdminAnalytics { Count(&analytics.MessagesLast24Hours) db.Model(&Message{}).Distinct("room").Count(&analytics.DistinctRooms) db.Model(&ModerationLog{}).Count(&analytics.ModerationActions) + db.Model(&Media{}).Count(&analytics.MediaCount) + db.Model(&Media{}).Select("COALESCE(SUM(duration_seconds), 0)").Scan(&analytics.MediaTotalDurationSeconds) + analytics.MediaTotalRuntime = formatDuration(int(analytics.MediaTotalDurationSeconds)) var topRooms []RoomAnalytics db.Model(&Message{}). @@ -784,37 +876,126 @@ func handleAdminUsers(w http.ResponseWriter, r *http.Request) { } func listMediaFiles() ([]MediaFileEntry, error) { - mediaDir := getMediaDir() - entries, err := os.ReadDir(mediaDir) - if err != nil { + var records []Media + if err := db.Order("uploaded_at desc").Find(&records).Error; err != nil { return nil, err } - files := make([]MediaFileEntry, 0, len(entries)) - for _, entry := range entries { - if entry.IsDir() { - continue - } - - info, err := entry.Info() - if err != nil { - continue - } - + files := make([]MediaFileEntry, 0, len(records)) + for _, record := range records { files = append(files, MediaFileEntry{ - Name: entry.Name(), - Size: info.Size(), - ModifiedAt: info.ModTime().UTC().Format(time.RFC3339), + ID: record.ID, + Name: record.Filename, + OriginalName: record.OriginalName, + Path: record.Path, + Size: record.SizeBytes, + DurationSeconds: record.DurationSeconds, + Runtime: formatDuration(record.DurationSeconds), + Status: record.Status, + Title: record.Title, + Tags: record.Tags, + UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339), + ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339), }) } sort.Slice(files, func(i, j int) bool { - return files[i].Name < files[j].Name + if files[i].UploadedAt == files[j].UploadedAt { + return files[i].Name < files[j].Name + } + return files[i].UploadedAt > files[j].UploadedAt }) return files, nil } +func formatDuration(seconds int) string { + if seconds < 0 { + seconds = 0 + } + hours := seconds / 3600 + minutes := (seconds % 3600) / 60 + remainingSeconds := seconds % 60 + return fmt.Sprintf("%02d:%02d:%02d", hours, minutes, remainingSeconds) +} + +func getMediaIDFromPath(path string) (uint, error) { + prefix := "/api/admin/media/" + if !strings.HasPrefix(path, prefix) { + return 0, fmt.Errorf("invalid media path") + } + idPart := strings.TrimPrefix(path, prefix) + idPart = strings.TrimSpace(idPart) + if idPart == "" || strings.Contains(idPart, "/") { + return 0, fmt.Errorf("invalid media id") + } + + id, err := strconv.ParseUint(idPart, 10, 64) + if err != nil || id == 0 { + return 0, fmt.Errorf("invalid media id") + } + + return uint(id), nil +} + +func mediaEntryFromRecord(record Media) MediaFileEntry { + return MediaFileEntry{ + ID: record.ID, + Name: record.Filename, + OriginalName: record.OriginalName, + Path: record.Path, + Size: record.SizeBytes, + DurationSeconds: record.DurationSeconds, + Runtime: formatDuration(record.DurationSeconds), + Status: record.Status, + Title: record.Title, + Tags: record.Tags, + UploadedAt: record.UploadedAt.UTC().Format(time.RFC3339), + ModifiedAt: record.UpdatedAt.UTC().Format(time.RFC3339), + } +} + +func probeMediaDurationSeconds(mediaPath string) (int, error) { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + + out, err := exec.CommandContext( + ctx, + "ffprobe", + "-v", "error", + "-show_entries", "format=duration", + "-of", "default=noprint_wrappers=1:nokey=1", + mediaPath, + ).CombinedOutput() + + if ctx.Err() == context.DeadlineExceeded { + return 0, fmt.Errorf("ffprobe timed out") + } + if err != nil { + return 0, fmt.Errorf("ffprobe failed: %w (%s)", err, strings.TrimSpace(string(out))) + } + + rawDuration := strings.TrimSpace(string(out)) + if rawDuration == "" { + return 0, fmt.Errorf("ffprobe returned empty duration") + } + + secondsFloat, err := strconv.ParseFloat(rawDuration, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse duration %q: %w", rawDuration, err) + } + if secondsFloat < 0 { + secondsFloat = 0 + } + + seconds := int(secondsFloat + 0.5) + if secondsFloat > 0 && seconds == 0 { + seconds = 1 + } + + return seconds, nil +} + func handleAdminMedia(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodGet: @@ -851,12 +1032,49 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) { http.Error(w, fmt.Sprintf("failed to create media file: %v", err), http.StatusInternalServerError) return } - defer targetFile.Close() if _, err := io.Copy(targetFile, file); err != nil { + targetFile.Close() + _ = os.Remove(targetPath) http.Error(w, fmt.Sprintf("failed to store media file: %v", err), http.StatusInternalServerError) return } + if err := targetFile.Close(); err != nil { + _ = os.Remove(targetPath) + http.Error(w, fmt.Sprintf("failed to finalize media file: %v", err), http.StatusInternalServerError) + return + } + + fileInfo, err := os.Stat(targetPath) + if err != nil { + _ = os.Remove(targetPath) + http.Error(w, fmt.Sprintf("failed to read media file info: %v", err), http.StatusInternalServerError) + return + } + + durationSeconds, err := probeMediaDurationSeconds(targetPath) + if err != nil { + _ = os.Remove(targetPath) + http.Error(w, fmt.Sprintf("failed to probe media duration: %v", err), http.StatusBadRequest) + return + } + + filename := filepath.Base(header.Filename) + mediaRecord := Media{ + Filename: filename, + OriginalName: header.Filename, + Path: targetPath, + SizeBytes: fileInfo.Size(), + DurationSeconds: durationSeconds, + Status: "ready", + Title: strings.TrimSuffix(filename, filepath.Ext(filename)), + Tags: "", + } + if err := db.Create(&mediaRecord).Error; err != nil { + _ = os.Remove(targetPath) + http.Error(w, fmt.Sprintf("failed to persist media metadata: %v", err), http.StatusInternalServerError) + return + } files, err := listMediaFiles() if err != nil { @@ -871,6 +1089,90 @@ func handleAdminMedia(w http.ResponseWriter, r *http.Request) { } } +func handleAdminMediaByID(w http.ResponseWriter, r *http.Request) { + mediaID, err := getMediaIDFromPath(r.URL.Path) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + var record Media + if err := db.First(&record, mediaID).Error; err != nil { + http.Error(w, "media item not found", http.StatusNotFound) + return + } + + switch r.Method { + case http.MethodGet: + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mediaEntryFromRecord(record)) + case http.MethodPut: + var payload struct { + Status string `json:"status"` + Title string `json:"title"` + Tags string `json:"tags"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest) + return + } + + updates := map[string]any{} + if payload.Status != "" { + updates["status"] = strings.TrimSpace(payload.Status) + } + if payload.Title != "" { + updates["title"] = strings.TrimSpace(payload.Title) + } + updates["tags"] = strings.TrimSpace(payload.Tags) + + if err := db.Model(&record).Updates(updates).Error; err != nil { + http.Error(w, fmt.Sprintf("failed to update media metadata: %v", err), http.StatusInternalServerError) + return + } + if err := db.First(&record, mediaID).Error; err != nil { + http.Error(w, "media item not found after update", http.StatusNotFound) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(mediaEntryFromRecord(record)) + case http.MethodDelete: + absMediaDir, err := filepath.Abs(getMediaDir()) + if err != nil { + http.Error(w, fmt.Sprintf("failed to resolve media directory: %v", err), http.StatusInternalServerError) + return + } + + absTargetPath, err := filepath.Abs(record.Path) + if err != nil { + http.Error(w, fmt.Sprintf("failed to resolve media path: %v", err), http.StatusInternalServerError) + return + } + + prefix := absMediaDir + string(os.PathSeparator) + if absTargetPath != absMediaDir && !strings.HasPrefix(absTargetPath, prefix) { + http.Error(w, "media path consistency check failed", http.StatusConflict) + return + } + + if err := os.Remove(absTargetPath); err != nil && !os.IsNotExist(err) { + http.Error(w, fmt.Sprintf("failed to delete media file: %v", err), http.StatusInternalServerError) + return + } + + if err := db.Delete(&record).Error; err != nil { + http.Error(w, fmt.Sprintf("failed to delete media record: %v", err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{"deleted": true, "id": mediaID}) + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + func countConnectedClients() int { mutex.Lock() defer mutex.Unlock() @@ -976,7 +1278,7 @@ func broadcast(msg Message) { mutex.Lock() defer mutex.Unlock() - // Save to SQLite + // Persist to configured SQL backend. db.Create(&msg) for client := range clients { @@ -1054,7 +1356,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) { log.Printf("Chat message in room %s from %s: %s", msg.Room, msg.Username, msg.Text) broadcast(msg) - // Save to SQLite (already done in broadcast, but ensure for private DMs) + // Save private messages explicitly as well. if msg.Type == "private" { db.Create(&msg) } @@ -1063,7 +1365,7 @@ func handleConnection(w http.ResponseWriter, r *http.Request) { // handlePrivateDM routes private messages to the specific user pair func handlePrivateDM(msg Message) { - // Save to SQLite for history + // Persist private messages for history. db.Create(&msg) // Broadcast only to participants (simplified - in production use a map of user connections) @@ -1122,6 +1424,7 @@ func main() { http.HandleFunc("/api/admin/users", requireAdminAuth(handleAdminUsers)) http.HandleFunc("/api/admin/schedule", requireAdminAuth(handleAdminSchedule)) http.HandleFunc("/api/admin/media", requireAdminAuth(handleAdminMedia)) + http.HandleFunc("/api/admin/media/", requireAdminAuth(handleAdminMediaByID)) http.HandleFunc("/metrics", handleMetrics) http.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") diff --git a/docs/architecture/adr/ADR-006-data-storage.md b/docs/architecture/adr/ADR-006-data-storage.md index fed36f2..0e33193 100644 --- a/docs/architecture/adr/ADR-006-data-storage.md +++ b/docs/architecture/adr/ADR-006-data-storage.md @@ -1,7 +1,7 @@ # ADR-006: Data Storage Strategy (SQLite vs PostgreSQL) -**Status:** Proposed -**Date:** 2026-08-02 +**Status:** Accepted +**Date:** 2026-08-04 **Author:** AI Assistant (following AGENTS.md, ADR-002, ADR-003, ADR-004, ADR-005) **Related ADRs:** [ADR-001](./ADR-001.md), [ADR-003](./ADR-003-tech-stack.md), [ADR-005](./ADR-005-admin-moderation-analytics.md), [ADR-007](./ADR-007-redis-usage.md) @@ -12,89 +12,78 @@ The system needs persistent storage for: - Chat messages and history (public rooms and private DMs). - User accounts, roles, and ban lists. - Moderation audit log (immutable record of admin and moderator actions). -- Playlist metadata and schedule overrides. -- System configuration (including Redis settings adjustable via admin UI per ADR-005/ADR-007). +- Playlist and media metadata used by admin flows. +- System configuration visibility in admin APIs. -From ADR-003 we prefer operational simplicity. ADR-005 requires a robust audit trail. We must decide between **SQLite** (embedded, zero-config) and **PostgreSQL** (full-featured, scalable) and document how the admin interface can view and adjust storage-related configuration. +The codebase now ships with a PostgreSQL service in Docker Compose (`db`) and chat is configured with a PostgreSQL `DATABASE_URL` by default. The backend still supports SQLite for optional local fallback, but the deployed and compose-default path is PostgreSQL. ## Decision Drivers -1. **Operational Simplicity** – Minimize infrastructure (no separate DB server for SQLite). -2. **Performance & Concurrency** – Chat and moderation may have high write volume. -3. **Backup and Recovery** – Easy point-in-time recovery and replication. -4. **Admin Capabilities** – Admin UI must be able to view storage stats, run vacuum/analyze, and switch between SQLite and PostgreSQL (with migration path). -5. **Auditability** – Moderation log must be immutable and queryable. -6. **Cost** – Avoid managed database costs during early stages. +1. **Implemented Reality** - Docker Compose already provisions PostgreSQL and chat depends on it. +2. **Concurrency & Throughput** - Chat, moderation, and media metadata writes require stronger concurrent write behavior. +3. **Operational Traceability** - Structured backups, service health checks, and persistent volume management are already in place for PostgreSQL. +4. **Admin Growth** - Upcoming admin features (media CRUD, analytics, moderation tooling) benefit from relational querying and indexing. +5. **Fallback Simplicity** - Keep SQLite support for lightweight local or recovery scenarios without blocking developer workflows. ## Decision -**Primary storage for v1: SQLite** (embedded in the chat and admin services). +**Primary storage for current implementation: PostgreSQL (Docker Compose `db` service).** -**Migration Path**: Provide a clear upgrade path to PostgreSQL when concurrency or replication needs exceed SQLite capabilities (target v2 or when concurrent users > 500). +**Secondary/Fallback mode:** SQLite remains supported via `DATABASE_URL=sqlite://...` for local-only scenarios. -**Admin Interface Capabilities** (extension of ADR-005): +**Current Backend Behavior**: -- "System Configuration" tab showing current storage engine, database size, connection stats, and vacuum status. -- Buttons to run `VACUUM`, `ANALYZE`, or trigger a backup. -- Configuration form to switch connection string (SQLite file path vs PostgreSQL URL) and restart services gracefully. -- Audit log of all configuration changes. +- Chat backend (`backend/chat/src/main.go`) resolves `DATABASE_URL` at startup. +- `postgres://` or `postgresql://` selects PostgreSQL driver. +- `sqlite://` selects SQLite driver. +- Empty `DATABASE_URL` falls back to local `chat.db` for developer convenience. +- GORM AutoMigrate is used for chat/admin data models. -**Technical Details**: +**Media Model Decision (Admin UI Feature Track)**: -- Use SQLite with WAL mode for better concurrency. -- Use GORM or sqlx (Go) or Prisma (if Node.js fallback) for ORM/migrations. -- Store large chat history in a separate table with retention policy (e.g., 30 days). -- Moderation log as an append-only table with triggers for immutability. - -**Folder Placement** (per ADR-002): - -- Database models and migrations in `backend/shared/db/` or `backend/chat/internal/db/`. -- Admin UI pages for storage management in `frontend/admin/system/`. +- Media metadata is persisted in the same database as messages and moderation logs. +- Physical media files remain on the shared filesystem (`/app/media`, mapped from `data/media`). +- The database is the source of truth for media library metadata and future runtime analytics. ## Rationale -- SQLite aligns with ADR-003's emphasis on operational simplicity and low resource usage for the initial deployment on a single Hetzner VPS. -- It eliminates the need for a separate database container in `docker-compose.yml` during early development. -- The admin interface can provide visibility and basic maintenance operations, fulfilling the requirement from the previous TODO plan. -- PostgreSQL can be introduced later with a migration script when scale demands it (replication, advanced querying, full-text search on chat). +- The repository already operationalizes PostgreSQL in both `docker-compose.yml` and `docker-compose.prod.yml` with persistent storage. +- Default environment documentation (`.env.example`, setup docs) already points to PostgreSQL. +- This aligns architecture documentation with implemented behavior and avoids drift. +- Keeping SQLite support preserves local flexibility while avoiding a hard breaking change. ## Consequences **Positive**: -- Zero-config database for quick local development and small-scale production. -- Simple backups (just copy the `.db` file). -- Admin UI can directly manage and monitor storage without external tools. +- Better write concurrency and query flexibility for chat + admin workloads. +- Clear operational model for containerized deployments (health checks, named volume, service dependency). +- Easier extension for admin analytics and media metadata features. **Negative / Risks**: -- SQLite has limited concurrency compared to PostgreSQL (WAL mode helps but is not a full replacement). -- Large chat history or analytics queries may become slow – plan retention policy and archiving. -- Migration to PostgreSQL will require downtime or dual-write strategy (documented in this ADR). +- Additional service to operate versus embedded-only SQLite. +- Misconfigured `DATABASE_URL` can still route to SQLite fallback unintentionally if left empty outside compose. +- Requires backup/restore procedures for PostgreSQL volume in production runbooks. -**Alternatives Considered**: +## Alternatives Considered -- PostgreSQL from day one – rejected for added operational complexity (separate service, connection pooling, managed service cost). -- Pure key-value store (e.g., BoltDB) – rejected because SQL is better for audit logs and complex moderation queries. -- Managed database (Neon, Supabase) – rejected to maintain full self-hosted independence per ADR-001. +- SQLite-first in production: rejected for current implementation because compose and deployment already run PostgreSQL. +- PostgreSQL-only with no fallback: rejected to preserve local dev and troubleshooting flexibility. +- Managed external PostgreSQL: deferred; self-hosted compose remains the preferred operational model. -## Implementation Plan +## Implementation Status -1. Create this ADR-006 (done). -2. Add SQLite support in `backend/shared/db/` with GORM migrations. -3. Implement admin UI pages for storage monitoring and configuration (linked to ADR-005). -4. Add configuration reload capability without full restart where possible. -5. Write migration script from SQLite to PostgreSQL. -6. Update `docker-compose.yml` to optionally include PostgreSQL. -7. Add tests for data access patterns and admin configuration changes. - -**Next ADR**: ADR-007 (Redis usage and admin configuration capabilities). +1. ADR updated to match implemented state. Done. +2. Compose includes PostgreSQL service and persistent volume. Done. +3. Chat backend supports PostgreSQL and SQLite based on `DATABASE_URL`. Done. +4. Media metadata persistence model documented here for Admin UI track. In progress (implementation ongoing). +5. Admin storage observability and operations UI (vacuum/analyze/backup flows). Planned. ## References - ADR-001: Chat persistence, moderation log, monitoring requirements. - ADR-003: Preference for operational simplicity and Go backend. -- ADR-005: Admin interface for user management, moderation, and analytics – this ADR extends it with storage and Redis config capabilities. -- arc42 documentation: Cross-cutting Concepts (data management, monitoring) and Risks & Technical Debts. - -This ADR ensures all data storage decisions are documented before code is written, maintaining full traceability. +- ADR-005: Admin interface for user management, moderation, and analytics. +- ADR-007: Redis usage for presence, rate limiting, pub/sub. +- `docker-compose.yml` and `docker-compose.prod.yml` database service definitions. diff --git a/frontend/admin/index.html b/frontend/admin/index.html index c605532..0dab2f1 100644 --- a/frontend/admin/index.html +++ b/frontend/admin/index.html @@ -38,7 +38,7 @@