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:
parent
21a595c3ce
commit
b42aa35ee8
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
selectNode(node)
|
||||
if (ev.targetType === 'note') {
|
||||
selectNode(node)
|
||||
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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue