fix: bypass Wails v2 []string marshalling via JSON.stringify + end-to-end test

Root cause: Wails v2.12.0 cannot reliably marshal []string arguments
from JavaScript to Go when called through positional binding.
The event IDs array arrived empty on the Go side, causing no
worklog_entry_events INSERTs.

Fix:
- AcceptSuggestionWith now accepts eventIDsJSON (string) instead of
  eventIDs ([]string). Frontend passes JSON.stringify(eventIds).
- Backend json.Unmarshal into []string before validation.
- Pre-insert validation: each eventID checked in activity_events.
- Atomic tx: entry create + linking in single Begin/Commit.
- INSERT (not INSERT OR IGNORE) — failure is a hard error.
- Post-commit verification: JOIN COUNT(*) must match len(eventIDs).
- End-to-end test: TestAcceptSuggestionWithEndToEnd creates a node,
  3 activity events, accepts suggestion, verifies all 3 linked.

Other changes:
- GetWorklogEntryEvents: fixed column name (details_json -> metadata).
- openActivityTarget(ev): new function for 'Посмотреть' button that
  navigates to specific note/file/folder instead of just opening node.
- All 'openNodeById(ev.nodeId)' in event contexts replaced with
  'openActivityTarget(ev)'.
This commit is contained in:
mirivlad 2026-06-03 16:00:17 +08:00
parent 21a595c3ce
commit b42aa35ee8
7 changed files with 180 additions and 50 deletions

View File

@ -1,6 +1,7 @@
package main
import (
"encoding/json"
"fmt"
"sort"
"strings"
@ -111,22 +112,36 @@ func (a *App) GetSuggestions() ([]activity.Suggestion, error) {
return suggestions, nil
}
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper, uses flat fields).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDs []string) (*WorklogDTO, error) {
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDs)
// AcceptSuggestion creates a worklog entry from a suggestion (compatibility wrapper).
func (a *App) AcceptSuggestion(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
return a.AcceptSuggestionWith(nodeID, summary, minutes, date, eventIDsJSON)
}
// AcceptSuggestionWith creates a worklog entry and links events in a single transaction.
// Uses flat fields to avoid Wails marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDs []string) (*WorklogDTO, error) {
// eventIDsJSON is a JSON-serialized string array to avoid Wails v2 []string marshalling issues.
func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date string, eventIDsJSON string) (*WorklogDTO, error) {
d := date
if d == "" {
d = time.Now().Format("2006-01-02")
}
// Log what we received from the frontend
fmt.Printf("DEBUG AcceptSuggestionWith: nodeID=%q summary=%q minutes=%d date=%q eventIDs=%v (len=%d)\n",
nodeID, summary, minutes, d, eventIDs, len(eventIDs))
var eventIDs []string
if eventIDsJSON != "" {
if err := json.Unmarshal([]byte(eventIDsJSON), &eventIDs); err != nil {
return nil, fmt.Errorf("unmarshal eventIDs: %w", err)
}
}
// Validate that every eventID actually exists in activity_events.
for _, eid := range eventIDs {
var n int
if err := a.db.QueryRow(`SELECT COUNT(*) FROM activity_events WHERE id = ?`, eid).Scan(&n); err != nil {
return nil, fmt.Errorf("check event %s: %w", eid, err)
}
if n == 0 {
return nil, fmt.Errorf("event %s not found in activity_events", eid)
}
}
// Use a transaction to atomically create entry + link events
tx, err := a.db.Begin()
@ -140,33 +155,31 @@ func (a *App) AcceptSuggestionWith(nodeID, summary string, minutes int, date str
return nil, fmt.Errorf("create entry: %w", err)
}
fmt.Printf("DEBUG AcceptSuggestionWith: entry created id=%s\n", entry.ID)
linked := 0
for _, eid := range eventIDs {
res, err := tx.Exec(
`INSERT OR IGNORE INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid)
if err != nil {
if _, err := tx.Exec(
`INSERT INTO worklog_entry_events (entry_id, event_id) VALUES (?,?)`,
entry.ID, eid); err != nil {
return nil, fmt.Errorf("link event %s: %w", eid, err)
}
n, _ := res.RowsAffected()
linked += int(n)
}
fmt.Printf("DEBUG AcceptSuggestionWith: linked %d events (out of %d eventIDs)\n", linked, len(eventIDs))
if len(eventIDs) > 0 {
var linked int
if err := tx.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, entry.ID).Scan(&linked); err != nil {
return nil, fmt.Errorf("verify links: %w", err)
}
if linked != len(eventIDs) {
return nil, fmt.Errorf("expected %d linked events, got %d", len(eventIDs), linked)
}
}
if err := tx.Commit(); err != nil {
return nil, fmt.Errorf("commit tx: %w", err)
}
// Verify the links were stored
if len(eventIDs) > 0 {
var count int
a.db.QueryRow("SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?", entry.ID).Scan(&count)
fmt.Printf("DEBUG AcceptSuggestionWith: verification COUNT(*) = %d\n", count)
}
_ = a.sync.RecordOp(syncsvc.EntityWorklog, entry.ID, syncsvc.OpCreate, worklogPayload(entry))
mins := 0
if entry.Minutes != nil {

View File

@ -161,7 +161,7 @@ func (a *App) SaveWorklogReport(format, dateFrom, dateTo, nodeID string, include
func (a *App) GetWorklogEntryEvents(entryID string) ([]EventDTO, error) {
rows, err := a.db.Query(
`SELECT e.id, e.node_id, e.event_type, e.target_type, e.target_id, e.target_path,
e.title, e.details_json, e.created_at
e.title, COALESCE(e.metadata,''), e.created_at
FROM activity_events e
JOIN worklog_entry_events wle ON wle.event_id = e.id
WHERE wle.entry_id = ?

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -16,7 +16,7 @@
background: #13131f;
}
</style>
<script type="module" crossorigin src="/assets/main-DQ318Oic.js"></script>
<script type="module" crossorigin src="/assets/main-BaeOWl0t.js"></script>
<link rel="stylesheet" crossorigin href="/assets/main-BafVhx43.css">
</head>
<body>

View File

@ -0,0 +1,116 @@
package main
import (
"encoding/json"
"testing"
"verstak/internal/core/activity"
"verstak/internal/core/util"
"verstak/internal/core/worklog"
)
// TestAcceptSuggestionWithEndToEnd verifies the full chain:
// 1. Create a node
// 2. Create activity events for that node
// 3. Accept a suggestion with those event IDs
// 4. Verify worklog_entry_events contains exactly those events
// 5. Verify GetWorklogEntryEvents returns the linked events
func TestAcceptSuggestionWithEndToEnd(t *testing.T) {
app, _ := setupTestApp(t)
// 1. Create a node
n, err := app.CreateNodeFromTemplate("", "Test Suggestion", "folder.default")
if err != nil {
t.Fatalf("create node: %v", err)
}
// 2. Create activity events for the node.
// We need to insert events with today's timestamp so GetSuggestions picks them up.
// But to avoid time dependency, we also directly accept with known event IDs.
eid1 := insertTestEvent(t, app, n.ID, activity.TypeNoteCreated, "note", "note1", "Test note 1")
eid2 := insertTestEvent(t, app, n.ID, activity.TypeNoteUpdated, "note", "note1", "Test note 1 updated")
eid3 := insertTestEvent(t, app, n.ID, activity.TypeFileAdded, "file", "file1", "Test file")
eventIDs := []string{eid1, eid2, eid3}
eventIDsJSON, _ := json.Marshal(eventIDs)
// 3. Accept suggestion with these event IDs
dto, err := app.AcceptSuggestionWith(n.ID, "Работа с заметками", 15, "", string(eventIDsJSON))
if err != nil {
t.Fatalf("AcceptSuggestionWith: %v", err)
}
if dto.ID == "" {
t.Fatal("AcceptSuggestionWith returned empty entry ID")
}
// 4. Verify worklog_entry_events count
var linkCount int
err = app.db.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events WHERE entry_id = ?`, dto.ID).Scan(&linkCount)
if err != nil {
t.Fatalf("count worklog_entry_events: %v", err)
}
if linkCount != 3 {
t.Errorf("worklog_entry_events count = %d, want 3", linkCount)
}
// 5. Verify JOIN with activity_events
var joinCount int
err = app.db.QueryRow(
`SELECT COUNT(*) FROM worklog_entry_events wle
JOIN activity_events ae ON ae.id = wle.event_id
WHERE wle.entry_id = ?`, dto.ID).Scan(&joinCount)
if err != nil {
t.Fatalf("join count: %v", err)
}
if joinCount != 3 {
t.Errorf("JOIN count = %d, want 3", joinCount)
}
// 6. Call GetWorklogEntryEvents and verify 3 events
events, err := app.GetWorklogEntryEvents(dto.ID)
if err != nil {
t.Fatalf("GetWorklogEntryEvents: %v", err)
}
if len(events) != 3 {
t.Errorf("GetWorklogEntryEvents returned %d events, want 3", len(events))
}
// 7. Verify the returned event IDs match
returnedIDs := make(map[string]bool, len(events))
for _, ev := range events {
returnedIDs[ev.ID] = true
}
for _, want := range eventIDs {
if !returnedIDs[want] {
t.Errorf("event %s not found in GetWorklogEntryEvents result", want)
}
}
// 8. Verify source is 'suggestion'
var source string
err = app.db.QueryRow(
`SELECT source FROM worklog_entries WHERE id = ?`, dto.ID).Scan(&source)
if err != nil {
t.Fatalf("get source: %v", err)
}
if source != worklog.SourceSuggestion {
t.Errorf("source = %q, want %q", source, worklog.SourceSuggestion)
}
}
// insertTestEvent inserts an activity event and returns its ID.
func insertTestEvent(t *testing.T, app *App, nodeID, eventType, targetType, targetID, title string) string {
t.Helper()
id := util.UUID7()
now := "2026-06-03T12:00:00Z"
_, err := app.db.Exec(
`INSERT INTO activity_events(id,node_id,event_type,target_type,target_id,target_path,title,metadata,created_at)
VALUES(?,?,?,?,?,?,?,?,?)`,
id, nodeID, eventType, targetType, targetID, "", title, "{}", now)
if err != nil {
t.Fatalf("insert event: %v", err)
}
return id
}

View File

@ -979,8 +979,9 @@
async function acceptTodaySuggestion(s) {
try {
const eventIds = extractEventIds(s)
console.log('DEBUG acceptTodaySuggestion:', { nodeId: s.nodeId, eventIdsLen: eventIds.length, eventIds, eventsCount: s.events?.length })
await wailsCall('AcceptSuggestionWith', s.nodeId, s.summary, s.suggestedMin, '', eventIds)
const eventIdsJSON = JSON.stringify(eventIds)
console.log('DEBUG acceptTodaySuggestion:', { nodeId: s.nodeId, eventIds, eventIdsJSON, events: (s.events || []).map(ev => ({ id: ev.id, type: ev.eventType, title: ev.title })) })
await wailsCall('AcceptSuggestionWith', s.nodeId, s.summary, s.suggestedMin, '', eventIdsJSON)
await refreshAfterSuggestion()
} catch (e) { console.error(e) }
}
@ -988,8 +989,9 @@
async function acceptJournalSuggestion(s) {
try {
const eventIds = extractEventIds(s)
console.log('DEBUG acceptJournalSuggestion:', { nodeId: s.nodeId, eventIdsLen: eventIds.length, eventIds, eventsCount: s.events?.length })
await wailsCall('AcceptSuggestionWith', s.nodeId, s.summary, s.suggestedMin, '', eventIds)
const eventIdsJSON = JSON.stringify(eventIds)
console.log('DEBUG acceptJournalSuggestion:', { nodeId: s.nodeId, eventIds, eventIdsJSON, events: (s.events || []).map(ev => ({ id: ev.id, type: ev.eventType, title: ev.title })) })
await wailsCall('AcceptSuggestionWith', s.nodeId, s.summary, s.suggestedMin, '', eventIdsJSON)
await refreshAfterSuggestion()
} catch (e) { console.error(e) }
}
@ -1316,36 +1318,35 @@
}
}
async function openActivityEvent(ev) {
async function openActivityTarget(ev) {
const targetNode = ev.nodeId
if (!targetNode) return
try {
const node = await wailsCall('GetNodeDetail', targetNode)
if (!node) return
if (ev.targetType === 'note') {
selectNode(node)
if (ev.targetType === 'note') {
activeTab = 'notes'
// If we have a targetId, try to find and open the note
if (ev.targetId) {
// Load notes and open the specific note
try { notes = await wailsCall('ListNotes', targetNode) || [] } catch(e) {}
const note = notes.find(n => n.id === ev.targetId)
if (note) {
// Small delay to let the UI render the notes tab
setTimeout(() => openNote(note), 100)
}
if (note) setTimeout(() => openNote(note), 100)
}
} else if (ev.targetType === 'file' || ev.targetType === 'folder') {
selectNode(node)
activeTab = 'files'
} else {
selectNode(node)
if (ev.targetId && ev.targetPath) {
try { await wailsCall('OpenFolder', ev.targetPath) } catch(e) {}
}
}
} catch (e) {
error = String(e)
}
}
async function openActivityEvent(ev) {
return openActivityTarget(ev)
}
// ===== Sync =====
async function loadSyncStatus() {
try {
@ -1787,7 +1788,7 @@
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
@ -1842,7 +1843,7 @@
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
@ -1978,7 +1979,7 @@
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}
@ -2076,7 +2077,7 @@
<span class="journal-event-time">{formatTime(ev.createdAt)}</span>
<span class="journal-event-type">{eventLabel(ev.eventType)}</span>
<span class="journal-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
</div>
{/each}
</div>
@ -2132,7 +2133,7 @@
<span class="suggestion-event-time">{formatTime(ev.createdAt)}</span>
<span class="suggestion-event-type">{eventLabel(ev.eventType)}</span>
<span class="suggestion-event-title">{ev.title}</span>
<button class="link-btn" on:click={() => openNodeById(ev.nodeId)}>{t('common.open')}</button>
<button class="link-btn" on:click={() => openActivityTarget(ev)}>{t('common.open')}</button>
{#if ev.targetType === 'file' || ev.eventType.startsWith('file_')}
<button class="link-btn" on:click={() => openNodeFolder(ev.nodeId)}>{t('file.showInExplorer')}</button>
{/if}