From ecd4ea0a6462b667953597ba9f1f56fcf1f9e75c Mon Sep 17 00:00:00 2001 From: Stefan Ernst <24554551+stefan-ernst@users.noreply.github.com> Date: Wed, 11 Nov 2026 23:25:16 +0100 Subject: [PATCH] fix(planeimport): write imported comments through CommentService The importer inserted comment rows with raw SQL, which bypassed the comment chokepoint and left imported comments without a comment-created domain fact. Add CommentService.CreateImportedInTx, the in-transaction counterpart of CreateImported that preserves the source's created_at and updated_at, and route the importer through it. The batch transaction stays the durability unit, so the mapping write and the comment remain atomic. --- internal/planeimport/batch.go | 26 ++++++++++++++------------ internal/services/comment_service.go | 12 +++++++++++- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/internal/planeimport/batch.go b/internal/planeimport/batch.go index 48a89c35d..82f6f6ac8 100644 --- a/internal/planeimport/batch.go +++ b/internal/planeimport/batch.go @@ -13,6 +13,7 @@ import ( "windshift/internal/itemevents" "windshift/internal/models" "windshift/internal/repository" + "windshift/internal/services" ) // fallbackStatusID is the status used when a source state has no status row @@ -409,7 +410,7 @@ func (l *Loader) runBatch( if _, cDone := existing["comment"][c.ID]; cDone { l.report.CommentsExisting++ } else { - if err := l.insertComment(tx, existing, idOf(existing, i.ID), c); err != nil { + if err := l.insertComment(ctx, tx, existing, idOf(existing, i.ID), c); err != nil { return err } } @@ -465,7 +466,7 @@ func (l *Loader) runBatch( if c.DeletedAt != nil { continue } - if err := l.insertComment(tx, existing, id, c); err != nil { + if err := l.insertComment(ctx, tx, existing, id, c); err != nil { return err } } @@ -535,8 +536,10 @@ func commentContent(c *Comment) string { } // insertComment writes one comment row with the contract's author and -// timestamps, records its idempotency mapping, and counts it. -func (l *Loader) insertComment(tx database.Tx, existing map[string]map[string]int, itemID int, c *Comment) error { +// timestamps, records its idempotency mapping, and counts it. The row is +// written through CommentService so the comment-created domain fact is +// recorded inside the same batch transaction. +func (l *Loader) insertComment(ctx context.Context, tx database.Tx, existing map[string]map[string]int, itemID int, c *Comment) error { if _, done := existing["comment"][c.ID]; done { l.report.CommentsExisting++ return nil @@ -553,21 +556,20 @@ func (l *Loader) insertComment(tx database.Tx, existing map[string]map[string]in l.report.CommentsSkippedEmpty++ return nil } - var authorID any + var authorID int if id, ok := l.userID[c.CreatedByID]; ok { authorID = id } - var commentID int - if err := tx.QueryRow(` - INSERT INTO comments (item_id, author_id, content, is_private, created_at, updated_at) - VALUES (?, ?, ?, false, ?, ?) RETURNING id`, - itemID, authorID, content, c.CreatedAt, c.UpdatedAt).Scan(&commentID); err != nil { + commentID, err := services.NewCommentService(l.db).CreateImportedInTx( + ctx, tx, itemID, authorID, content, c.CreatedAt, c.UpdatedAt, itemevents.Import("plane")) + if err != nil { return fmt.Errorf("insert plane comment %s: %w", c.ID, err) } - if err := l.recordMapping(tx, "comment", c.ID, commentID, map[string]any{"plane_issue_present": true}); err != nil { + id := int(commentID) + if err := l.recordMapping(tx, "comment", c.ID, id, map[string]any{"plane_issue_present": true}); err != nil { return err } - existing["comment"][c.ID] = commentID + existing["comment"][c.ID] = id l.report.CommentsImported++ return nil } diff --git a/internal/services/comment_service.go b/internal/services/comment_service.go index f036f58b1..2900f2978 100644 --- a/internal/services/comment_service.go +++ b/internal/services/comment_service.go @@ -412,13 +412,23 @@ func (s *CommentService) UpdateImported(params UpdateImportedCommentParams) erro // themselves after they commit. authorID == 0 inserts a system (NULL author) // comment. func (s *CommentService) CreateInTx(ctx context.Context, tx database.Tx, itemID, authorID int, content string, createdAt time.Time, eventMetadata ...itemevents.Metadata) (int64, error) { + return s.CreateImportedInTx(ctx, tx, itemID, authorID, content, createdAt, createdAt, eventMetadata...) +} + +// CreateImportedInTx inserts an imported comment inside an existing +// transaction, preserving the source's created_at and updated_at. It is the +// in-transaction counterpart of CreateImported for importers that must write a +// comment atomically with their own rows, and it records the canonical +// comment-created fact with side effects suppressed. authorID == 0 inserts a +// system (NULL author) comment. +func (s *CommentService) CreateImportedInTx(ctx context.Context, tx database.Tx, itemID, authorID int, content string, createdAt, updatedAt time.Time, eventMetadata ...itemevents.Metadata) (int64, error) { content = normalizeImportedCommentSource(content) var author any if authorID != 0 { author = authorID } var id int64 - err := tx.QueryRowContext(ctx, insertCommentAuthorSQL, itemID, author, content, false, createdAt, createdAt).Scan(&id) + err := tx.QueryRowContext(ctx, insertCommentAuthorSQL, itemID, author, content, false, createdAt, updatedAt).Scan(&id) if err != nil { return 0, err }