package docs import ( "context" "database/sql" "encoding/json" "errors" "fmt" "time" ) type Repository struct { db *sql.DB } type DocumentRecord struct { ID string `json:"id"` Path string `json:"path"` CurrentHash string `json:"currentHash"` Title string `json:"title"` Tags []string `json:"tags"` CreatedAt time.Time `json:"createdAt"` UpdatedAt time.Time `json:"updatedAt"` ArchivedAt *time.Time `json:"archivedAt,omitempty"` } type UserRecord struct { ID string `json:"id"` Email string `json:"email"` DisplayName string `json:"displayName"` Role string `json:"role"` CreatedAt time.Time `json:"createdAt"` LastSeenAt time.Time `json:"lastSeenAt,omitempty"` } type AttachmentRecord struct { Hash string OriginalName string ContentType string SizeBytes int64 CreatedAt time.Time DocumentPath string } type PersistDocumentInput struct { ID string Path string Title string Hash string PreviousHash string Tags []string } func NewRepository(db *sql.DB) *Repository { return &Repository{db: db} } func (r *Repository) PersistDocument(ctx context.Context, input PersistDocumentInput) error { now := time.Now().UTC() tagsJSON, err := json.Marshal(input.Tags) if err != nil { return fmt.Errorf("marshal tags: %w", err) } existing, err := r.GetDocumentByPath(ctx, input.Path) if err != nil && !errors.Is(err, sql.ErrNoRows) { return err } documentID := input.ID createdAt := now if existing != nil { documentID = existing.ID createdAt = existing.CreatedAt } if _, err := r.db.ExecContext(ctx, ` INSERT INTO documents (id, path, current_hash, title, tags, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(path) DO UPDATE SET current_hash = excluded.current_hash, title = excluded.title, tags = excluded.tags, updated_at = excluded.updated_at `, documentID, input.Path, input.Hash, input.Title, string(tagsJSON), createdAt.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { return fmt.Errorf("upsert document %s: %w", input.Path, err) } if existing != nil && existing.CurrentHash == input.Hash { return nil } versionID := documentID + ":" + input.Hash[:12] if _, err := r.db.ExecContext(ctx, ` INSERT OR IGNORE INTO document_versions (id, document_id, hash, previous_hash, created_at, change_summary) VALUES (?, ?, ?, ?, ?, ?) `, versionID, documentID, input.Hash, input.PreviousHash, now.Format(time.RFC3339), "filesystem sync"); err != nil { return fmt.Errorf("insert document version %s: %w", input.Path, err) } return nil } func (r *Repository) GetDocumentByPath(ctx context.Context, path string) (*DocumentRecord, error) { return r.getDocumentByPath(ctx, path, false) } func (r *Repository) GetDocumentByPathIncludingArchived(ctx context.Context, path string) (*DocumentRecord, error) { return r.getDocumentByPath(ctx, path, true) } func (r *Repository) getDocumentByPath(ctx context.Context, path string, includeArchived bool) (*DocumentRecord, error) { var ( record DocumentRecord tagsJSON string created string updated string archived sql.NullString ) query := ` SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at FROM documents WHERE path = ? ` if !includeArchived { query += ` AND archived_at IS NULL` } err := r.db.QueryRowContext(ctx, query, path).Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived) if err != nil { return nil, err } if err := parseDocumentTimes(&record, created, updated); err != nil { return nil, err } if archived.Valid && archived.String != "" { t, err := time.Parse(time.RFC3339, archived.String) if err != nil { return nil, fmt.Errorf("parse document archived_at: %w", err) } record.ArchivedAt = &t } if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil { return nil, fmt.Errorf("unmarshal document tags: %w", err) } return &record, nil } func (r *Repository) ListDocuments(ctx context.Context) ([]DocumentRecord, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at FROM documents WHERE archived_at IS NULL ORDER BY path ASC `) if err != nil { return nil, fmt.Errorf("list documents: %w", err) } defer rows.Close() return scanDocumentRows(rows) } func (r *Repository) ListArchivedDocuments(ctx context.Context) ([]DocumentRecord, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, path, current_hash, title, tags, created_at, updated_at, archived_at FROM documents WHERE archived_at IS NOT NULL ORDER BY archived_at DESC `) if err != nil { return nil, fmt.Errorf("list archived documents: %w", err) } defer rows.Close() return scanDocumentRows(rows) } func scanDocumentRows(rows *sql.Rows) ([]DocumentRecord, error) { var records []DocumentRecord for rows.Next() { var ( record DocumentRecord tagsJSON string created string updated string archived sql.NullString ) if err := rows.Scan(&record.ID, &record.Path, &record.CurrentHash, &record.Title, &tagsJSON, &created, &updated, &archived); err != nil { return nil, fmt.Errorf("scan document: %w", err) } if err := parseDocumentTimes(&record, created, updated); err != nil { return nil, err } if archived.Valid && archived.String != "" { t, err := time.Parse(time.RFC3339, archived.String) if err != nil { return nil, fmt.Errorf("parse document archived_at: %w", err) } record.ArchivedAt = &t } if err := json.Unmarshal([]byte(tagsJSON), &record.Tags); err != nil { return nil, fmt.Errorf("unmarshal document tags: %w", err) } records = append(records, record) } return records, rows.Err() } func (r *Repository) ArchiveDocument(ctx context.Context, path string) error { now := time.Now().UTC().Format(time.RFC3339) result, err := r.db.ExecContext(ctx, ` UPDATE documents SET archived_at = ? WHERE path = ? AND archived_at IS NULL `, now, path) if err != nil { return fmt.Errorf("archive document %s: %w", path, err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("archive document rows affected %s: %w", path, err) } if n == 0 { return sql.ErrNoRows } return nil } func (r *Repository) RestoreDocument(ctx context.Context, path string) error { result, err := r.db.ExecContext(ctx, ` UPDATE documents SET archived_at = NULL WHERE path = ? AND archived_at IS NOT NULL `, path) if err != nil { return fmt.Errorf("restore document %s: %w", path, err) } n, err := result.RowsAffected() if err != nil { return fmt.Errorf("restore document rows affected %s: %w", path, err) } if n == 0 { return sql.ErrNoRows } return nil } func (r *Repository) ListUsers(ctx context.Context) ([]UserRecord, error) { rows, err := r.db.QueryContext(ctx, ` SELECT id, email, COALESCE(display_name, ''), COALESCE(role, 'viewer'), created_at, COALESCE(last_seen_at, '') FROM users ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("list users: %w", err) } defer rows.Close() var users []UserRecord for rows.Next() { var ( user UserRecord created string lastSeen string ) if err := rows.Scan(&user.ID, &user.Email, &user.DisplayName, &user.Role, &created, &lastSeen); err != nil { return nil, fmt.Errorf("scan user: %w", err) } createdAt, err := time.Parse(time.RFC3339, created) if err != nil { return nil, fmt.Errorf("parse user created_at: %w", err) } user.CreatedAt = createdAt if lastSeen != "" { parsed, err := time.Parse(time.RFC3339, lastSeen) if err != nil { return nil, fmt.Errorf("parse user last_seen_at: %w", err) } user.LastSeenAt = parsed } users = append(users, user) } return users, rows.Err() } func (r *Repository) UpsertUser(ctx context.Context, user UserRecord) (UserRecord, error) { now := time.Now().UTC() if user.ID == "" { user.ID = "user:" + user.Email } if user.Role == "" { user.Role = "viewer" } if user.DisplayName == "" { user.DisplayName = user.Email } user.CreatedAt = now user.LastSeenAt = now if _, err := r.db.ExecContext(ctx, ` INSERT INTO users (id, email, display_name, role, created_at, last_seen_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(email) DO UPDATE SET display_name = excluded.display_name, role = excluded.role, last_seen_at = excluded.last_seen_at `, user.ID, user.Email, user.DisplayName, user.Role, now.Format(time.RFC3339), now.Format(time.RFC3339)); err != nil { return UserRecord{}, fmt.Errorf("upsert user %s: %w", user.Email, err) } users, err := r.ListUsers(ctx) if err != nil { return UserRecord{}, err } for _, candidate := range users { if candidate.Email == user.Email { return candidate, nil } } return UserRecord{}, sql.ErrNoRows } func (r *Repository) SaveAttachment(ctx context.Context, record AttachmentRecord) error { if _, err := r.db.ExecContext(ctx, ` INSERT INTO attachments (hash, original_name, content_type, size_bytes, created_at, document_path) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(hash) DO UPDATE SET original_name = excluded.original_name, content_type = excluded.content_type, size_bytes = excluded.size_bytes, document_path = excluded.document_path `, record.Hash, record.OriginalName, record.ContentType, record.SizeBytes, record.CreatedAt.Format(time.RFC3339), record.DocumentPath); err != nil { return fmt.Errorf("save attachment: %w", err) } return nil } func (r *Repository) GetAttachment(ctx context.Context, hash string) (*AttachmentRecord, error) { var ( record AttachmentRecord created string ) err := r.db.QueryRowContext(ctx, ` SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '') FROM attachments WHERE hash = ? `, hash).Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath) if err != nil { return nil, err } parsed, err := time.Parse(time.RFC3339, created) if err != nil { return nil, fmt.Errorf("parse attachment created_at: %w", err) } record.CreatedAt = parsed return &record, nil } func (r *Repository) ListAttachments(ctx context.Context) ([]AttachmentRecord, error) { rows, err := r.db.QueryContext(ctx, ` SELECT hash, original_name, content_type, size_bytes, created_at, COALESCE(document_path, '') FROM attachments ORDER BY created_at DESC `) if err != nil { return nil, fmt.Errorf("list attachments: %w", err) } defer rows.Close() var attachments []AttachmentRecord for rows.Next() { var record AttachmentRecord var created string if err := rows.Scan(&record.Hash, &record.OriginalName, &record.ContentType, &record.SizeBytes, &created, &record.DocumentPath); err != nil { return nil, fmt.Errorf("scan attachment: %w", err) } record.CreatedAt, err = time.Parse(time.RFC3339, created) if err != nil { return nil, fmt.Errorf("parse attachment created_at: %w", err) } attachments = append(attachments, record) } return attachments, rows.Err() } type SearchResult struct { Path string `json:"path"` Title string `json:"title"` } func (r *Repository) SearchDocuments(ctx context.Context, query string) ([]SearchResult, error) { rows, err := r.db.QueryContext(ctx, ` SELECT d.path, d.title FROM document_search ds JOIN documents d ON d.rowid = ds.rowid WHERE document_search MATCH ? AND d.archived_at IS NULL ORDER BY rank LIMIT 50 `, query) if err != nil { return nil, fmt.Errorf("search documents: %w", err) } defer rows.Close() var results []SearchResult for rows.Next() { var result SearchResult if err := rows.Scan(&result.Path, &result.Title); err != nil { return nil, fmt.Errorf("scan search result: %w", err) } results = append(results, result) } return results, rows.Err() } func (r *Repository) UpdateSearchContent(ctx context.Context, path string, content string) error { if _, err := r.db.ExecContext(ctx, ` UPDATE document_search SET content = ? WHERE rowid = (SELECT rowid FROM documents WHERE path = ?) `, content, path); err != nil { return fmt.Errorf("update search content: %w", err) } return nil } func (r *Repository) Ping(ctx context.Context) error { return r.db.PingContext(ctx) } func parseDocumentTimes(record *DocumentRecord, created string, updated string) error { createdAt, err := time.Parse(time.RFC3339, created) if err != nil { return fmt.Errorf("parse document created_at: %w", err) } updatedAt, err := time.Parse(time.RFC3339, updated) if err != nil { return fmt.Errorf("parse document updated_at: %w", err) } record.CreatedAt = createdAt record.UpdatedAt = updatedAt return nil }