package sqlite import ( "context" "database/sql" "fmt" "time" "github.com/google/uuid" "github.com/jmoiron/sqlx" "github.com/kandev/kandev/internal/task/models" ) // docRevSelectCols lists the task_document_revisions columns in the fixed order // used by every SELECT in this file (and by scanDocRevisionRow / scanDocRevisionRows). const docRevSelectCols = `id, task_id, document_key, revision_number, title, content, author_kind, author_name, revert_of_revision_id, created_at, updated_at` // CreateDocument inserts a new task document HEAD row. func (r *Repository) CreateDocument(ctx context.Context, doc *models.TaskDocument) error { if doc.ID == "" { doc.ID = uuid.New().String() } now := time.Now().UTC() doc.CreatedAt = now doc.UpdatedAt = now _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_documents (id, task_id, key, type, title, content, author_kind, author_name, filename, mime_type, size_bytes, disk_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), doc.ID, doc.TaskID, doc.Key, doc.Type, doc.Title, doc.Content, doc.AuthorKind, doc.AuthorName, doc.Filename, doc.MimeType, doc.SizeBytes, doc.DiskPath, doc.CreatedAt, doc.UpdatedAt) if err != nil { return fmt.Errorf("create document: %w", err) } return nil } // GetDocument retrieves a document HEAD by task ID and key. Returns nil, nil when not found. func (r *Repository) GetDocument(ctx context.Context, taskID, key string) (*models.TaskDocument, error) { doc := &models.TaskDocument{} err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT id, task_id, key, type, title, content, author_kind, author_name, filename, mime_type, size_bytes, disk_path, created_at, updated_at FROM task_documents WHERE task_id = ? AND key = ? `), taskID, key).Scan( &doc.ID, &doc.TaskID, &doc.Key, &doc.Type, &doc.Title, &doc.Content, &doc.AuthorKind, &doc.AuthorName, &doc.Filename, &doc.MimeType, &doc.SizeBytes, &doc.DiskPath, &doc.CreatedAt, &doc.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("get document: %w", err) } return doc, nil } // UpdateDocument updates an existing document HEAD row. func (r *Repository) UpdateDocument(ctx context.Context, doc *models.TaskDocument) error { doc.UpdatedAt = time.Now().UTC() result, err := r.db.ExecContext(ctx, r.db.Rebind(` UPDATE task_documents SET type = ?, title = ?, content = ?, author_kind = ?, author_name = ?, filename = ?, mime_type = ?, size_bytes = ?, disk_path = ?, updated_at = ? WHERE task_id = ? AND key = ? `), doc.Type, doc.Title, doc.Content, doc.AuthorKind, doc.AuthorName, doc.Filename, doc.MimeType, doc.SizeBytes, doc.DiskPath, doc.UpdatedAt, doc.TaskID, doc.Key) if err != nil { return fmt.Errorf("update document: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("document not found: task=%s key=%s", doc.TaskID, doc.Key) } return nil } // DeleteDocument removes a document HEAD row (revisions cascade via FK). func (r *Repository) DeleteDocument(ctx context.Context, taskID, key string) error { result, err := r.db.ExecContext(ctx, r.db.Rebind( `DELETE FROM task_documents WHERE task_id = ? AND key = ?`, ), taskID, key) if err != nil { return fmt.Errorf("delete document: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("document not found: task=%s key=%s", taskID, key) } return nil } // ListDocuments returns all documents for a task, ordered by key. func (r *Repository) ListDocuments(ctx context.Context, taskID string) ([]*models.TaskDocument, error) { rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT id, task_id, key, type, title, content, author_kind, author_name, filename, mime_type, size_bytes, disk_path, created_at, updated_at FROM task_documents WHERE task_id = ? ORDER BY key `), taskID) if err != nil { return nil, fmt.Errorf("list documents: %w", err) } defer func() { _ = rows.Close() }() var out []*models.TaskDocument for rows.Next() { doc := &models.TaskDocument{} if err := rows.Scan( &doc.ID, &doc.TaskID, &doc.Key, &doc.Type, &doc.Title, &doc.Content, &doc.AuthorKind, &doc.AuthorName, &doc.Filename, &doc.MimeType, &doc.SizeBytes, &doc.DiskPath, &doc.CreatedAt, &doc.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan document: %w", err) } out = append(out, doc) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate documents: %w", err) } return out, nil } // InsertDocumentRevision inserts a new revision row. func (r *Repository) InsertDocumentRevision(ctx context.Context, rev *models.TaskDocumentRevision) error { if rev.ID == "" { rev.ID = uuid.New().String() } now := time.Now().UTC() if rev.CreatedAt.IsZero() { rev.CreatedAt = now } rev.UpdatedAt = now _, err := r.db.ExecContext(ctx, r.db.Rebind(` INSERT INTO task_document_revisions (`+docRevSelectCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), rev.ID, rev.TaskID, rev.DocumentKey, rev.RevisionNumber, rev.Title, rev.Content, rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID, rev.CreatedAt, rev.UpdatedAt) if err != nil { return fmt.Errorf("insert document revision: %w", err) } return nil } // GetDocumentRevision fetches a single revision by ID. func (r *Repository) GetDocumentRevision(ctx context.Context, id string) (*models.TaskDocumentRevision, error) { return r.scanDocRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind( `SELECT `+docRevSelectCols+` FROM task_document_revisions WHERE id = ?`, ), id)) } // GetLatestDocumentRevision returns the newest revision for a document (by revision_number DESC). func (r *Repository) GetLatestDocumentRevision(ctx context.Context, taskID, key string) (*models.TaskDocumentRevision, error) { return r.scanDocRevisionRow(r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT `+docRevSelectCols+` FROM task_document_revisions WHERE task_id = ? AND document_key = ? ORDER BY revision_number DESC LIMIT 1 `), taskID, key)) } // ListDocumentRevisions returns revisions newest-first. limit <= 0 returns all. func (r *Repository) ListDocumentRevisions(ctx context.Context, taskID, key string, limit int) ([]*models.TaskDocumentRevision, error) { query := `SELECT ` + docRevSelectCols + ` FROM task_document_revisions WHERE task_id = ? AND document_key = ? ORDER BY revision_number DESC` args := []interface{}{taskID, key} if limit > 0 { query += sqlLimitClause args = append(args, limit) } rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(query), args...) if err != nil { return nil, fmt.Errorf("list document revisions: %w", err) } defer func() { _ = rows.Close() }() var out []*models.TaskDocumentRevision for rows.Next() { rev := &models.TaskDocumentRevision{} var revertOf sql.NullString if err := rows.Scan( &rev.ID, &rev.TaskID, &rev.DocumentKey, &rev.RevisionNumber, &rev.Title, &rev.Content, &rev.AuthorKind, &rev.AuthorName, &revertOf, &rev.CreatedAt, &rev.UpdatedAt, ); err != nil { return nil, fmt.Errorf("scan document revision: %w", err) } if revertOf.Valid { v := revertOf.String rev.RevertOfRevisionID = &v } out = append(out, rev) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterate document revisions: %w", err) } return out, nil } // NextDocumentRevisionNumber returns max(revision_number)+1 for a document, or 1 if none exist. func (r *Repository) NextDocumentRevisionNumber(ctx context.Context, taskID, key string) (int, error) { var maxNum sql.NullInt64 err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT MAX(revision_number) FROM task_document_revisions WHERE task_id = ? AND document_key = ? `), taskID, key).Scan(&maxNum) if err != nil { return 0, fmt.Errorf("next document revision number: %w", err) } if !maxNum.Valid { return 1, nil } return int(maxNum.Int64) + 1, nil } // WriteDocumentRevision atomically upserts HEAD (task_documents) and either appends a new revision // or merges into an existing one in a single write transaction. // // Coalesce behavior: when coalesceLatestID is non-nil and non-empty, the revision with that // ID has title/content/updated_at merged in-place. When nil or empty, a new revision row is // inserted with revision_number = MAX(existing)+1. // // On success, rev is mutated to reflect the persisted state. func (r *Repository) WriteDocumentRevision( ctx context.Context, head *models.TaskDocument, rev *models.TaskDocumentRevision, coalesceLatestID *string, ) error { upsertHead := func(ctx context.Context, tx *sqlx.Tx, now time.Time) error { return upsertDocumentHead(ctx, tx, r.db, head, now) } merge := func(ctx context.Context, tx *sqlx.Tx, id string, now time.Time) error { return mergeDocRevisionInTx(ctx, tx, r.db, rev, id, now) } insert := func(ctx context.Context, tx *sqlx.Tx, now time.Time) error { return insertNewDocRevisionInTx(ctx, tx, r.db, rev, now) } return r.runRevisionTx(ctx, "document", coalesceLatestID, upsertHead, merge, insert) } // runRevisionTx runs the shared begin/upsertHead/coalesce-or-insert/commit transaction // pattern used by both document and plan revision writes. func (r *Repository) runRevisionTx( ctx context.Context, label string, coalesceLatestID *string, upsertHead func(context.Context, *sqlx.Tx, time.Time) error, merge func(context.Context, *sqlx.Tx, string, time.Time) error, insert func(context.Context, *sqlx.Tx, time.Time) error, ) error { tx, err := r.db.BeginTxx(ctx, nil) if err != nil { return fmt.Errorf("begin %s revision tx: %w", label, err) } defer func() { _ = tx.Rollback() }() now := time.Now().UTC() if err := upsertHead(ctx, tx, now); err != nil { return err } if coalesceLatestID != nil && *coalesceLatestID != "" { if err := merge(ctx, tx, *coalesceLatestID, now); err != nil { return err } } else { if err := insert(ctx, tx, now); err != nil { return err } } return tx.Commit() } func upsertDocumentHead(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, head *models.TaskDocument, now time.Time) error { if head.ID == "" { head.ID = uuid.New().String() } if head.CreatedAt.IsZero() { head.CreatedAt = now } head.UpdatedAt = now _, err := tx.ExecContext(ctx, db.Rebind(` INSERT INTO task_documents (id, task_id, key, type, title, content, author_kind, author_name, filename, mime_type, size_bytes, disk_path, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(task_id, key) DO UPDATE SET type = excluded.type, title = excluded.title, content = excluded.content, author_kind = excluded.author_kind, author_name = excluded.author_name, filename = excluded.filename, mime_type = excluded.mime_type, size_bytes = excluded.size_bytes, disk_path = excluded.disk_path, updated_at = excluded.updated_at `), head.ID, head.TaskID, head.Key, head.Type, head.Title, head.Content, head.AuthorKind, head.AuthorName, head.Filename, head.MimeType, head.SizeBytes, head.DiskPath, head.CreatedAt, head.UpdatedAt) if err != nil { return fmt.Errorf("upsert document head: %w", err) } return nil } func mergeDocRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskDocumentRevision, latestID string, now time.Time) error { result, err := tx.ExecContext(ctx, db.Rebind(` UPDATE task_document_revisions SET title = ?, content = ?, updated_at = ? WHERE id = ? `), rev.Title, rev.Content, now, latestID) if err != nil { return fmt.Errorf("merge document revision: %w", err) } rows, _ := result.RowsAffected() if rows == 0 { return fmt.Errorf("document revision not found: %s", latestID) } rev.ID = latestID rev.UpdatedAt = now return nil } func insertNewDocRevisionInTx(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, rev *models.TaskDocumentRevision, now time.Time) error { var maxNum sql.NullInt64 if err := tx.QueryRowContext(ctx, db.Rebind(` SELECT MAX(revision_number) FROM task_document_revisions WHERE task_id = ? AND document_key = ? `), rev.TaskID, rev.DocumentKey).Scan(&maxNum); err != nil { return fmt.Errorf("compute next document revision number: %w", err) } rev.RevisionNumber = 1 if maxNum.Valid { rev.RevisionNumber = int(maxNum.Int64) + 1 } if rev.ID == "" { rev.ID = uuid.New().String() } if rev.CreatedAt.IsZero() { rev.CreatedAt = now } rev.UpdatedAt = now _, err := tx.ExecContext(ctx, db.Rebind(` INSERT INTO task_document_revisions (`+docRevSelectCols+`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `), rev.ID, rev.TaskID, rev.DocumentKey, rev.RevisionNumber, rev.Title, rev.Content, rev.AuthorKind, rev.AuthorName, rev.RevertOfRevisionID, rev.CreatedAt, rev.UpdatedAt) if err != nil { return fmt.Errorf("insert document revision: %w", err) } return nil } func (r *Repository) scanDocRevisionRow(row *sql.Row) (*models.TaskDocumentRevision, error) { rev := &models.TaskDocumentRevision{} var revertOf sql.NullString err := row.Scan( &rev.ID, &rev.TaskID, &rev.DocumentKey, &rev.RevisionNumber, &rev.Title, &rev.Content, &rev.AuthorKind, &rev.AuthorName, &revertOf, &rev.CreatedAt, &rev.UpdatedAt, ) if err == sql.ErrNoRows { return nil, nil } if err != nil { return nil, fmt.Errorf("scan document revision: %w", err) } if revertOf.Valid { v := revertOf.String rev.RevertOfRevisionID = &v } return rev, nil }