From 4dffb05210fab7e9bfb5cedc745eae4fe3e2c958 Mon Sep 17 00:00:00 2001
From: fy59
Date: Fri, 18 Sep 2026 17:15:19 +0200
Subject: [PATCH] feat(web): complete session deletion and programs
---
.../trainlog/data/SyncGenerationService.kt | 2 +-
.../trainlog/data/TrainlogRepository.kt | 44 +-
.../data/AiSessionDraftRepositoryTest.kt | 35 +
tests/test_ai_session_draft.py | 27 +-
tests/test_web_sessions_browser.py | 9 +
tools/export_ai_session_drafts.py | 39 +-
tools/package_sync_candidate.py | 3 +-
tools/sync_generation_exchange.py | 2 +-
tui/include/trainlog/id.h | 3 +-
tui/include/trainlog/web_session_deletions.h | 27 +
tui/meson.build | 11 +
tui/src/database.c | 47 ++
tui/src/id.c | 2 +-
tui/src/web_prepared_items.c | 19 +-
tui/src/web_programs.c | 83 ++-
tui/src/web_server.c | 79 +++
tui/src/web_session_deletions.c | 600 ++++++++++++++++++
tui/src/web_sessions.c | 19 +-
tui/tests/test_schema_v21_migration.c | 5 +
tui/tests/test_web_programs.c | 145 ++++-
tui/tests/test_web_server.c | 96 ++-
tui/tests/test_web_session_deletions.c | 185 ++++++
web/src/api/programs.ts | 25 +-
web/src/api/sessions.ts | 31 +
.../components/DeleteConfirmationDialog.tsx | 79 +++
web/src/routes/ProgramsTab.test.tsx | 151 +++++
web/src/routes/ProgramsTab.tsx | 29 +-
web/src/routes/SessionsPage.test.tsx | 19 +-
web/src/routes/SessionsPage.tsx | 245 +++++--
web/src/theme/app.css | 73 ++-
30 files changed, 2010 insertions(+), 124 deletions(-)
create mode 100644 tui/include/trainlog/web_session_deletions.h
create mode 100644 tui/src/web_session_deletions.c
create mode 100644 tui/tests/test_web_session_deletions.c
create mode 100644 web/src/components/DeleteConfirmationDialog.tsx
create mode 100644 web/src/routes/ProgramsTab.test.tsx
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt
index f370828..86ed1ec 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/SyncGenerationService.kt
@@ -69,7 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
)
private val SUPPORTED =
(ANDROID_KINDS.values +
- Kind("trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", false) +
+ Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false))
.map { it.format to it.version }
.toSet()
diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt
index 1fa318b..230f122 100644
--- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt
+++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt
@@ -1721,15 +1721,26 @@ class TrainlogRepository(
val root = try { JSONObject(json) } catch (_: Exception) {
return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA JSON invalide.")
}
- if (!root.hasExactKeys(setOf("format", "version", "generated_at", "drafts")) ||
+ val version = root.value("version")
+ val expectedKeys = if (version.isExactJsonInteger(2)) {
+ setOf("format", "version", "generated_at", "drafts", "withdrawals")
+ } else {
+ setOf("format", "version", "generated_at", "drafts")
+ }
+ if (!root.hasExactKeys(expectedKeys) ||
root.value("format") != "trainlog-ai-session-drafts" ||
- !root.value("version").isExactJsonInteger(1) ||
+ (!version.isExactJsonInteger(1) && !version.isExactJsonInteger(2)) ||
!root.value("generated_at").isNonemptyJsonString() || root.value("drafts") !is JSONArray ||
!isUtcTimestamp(root.optString("generated_at"))) {
- return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA v1 non supporté.")
+ return AiSessionDraftImportResult.Invalid("Artifact de brouillons IA non supporté.")
}
val drafts = root.getJSONArray("drafts")
- if (drafts.length() > MAX_AI_DRAFTS)
+ val withdrawals = if (version.isExactJsonInteger(2)) {
+ root.getJSONArray("withdrawals")
+ } else {
+ JSONArray()
+ }
+ if (drafts.length() > MAX_AI_DRAFTS || withdrawals.length() > MAX_AI_DRAFTS)
return AiSessionDraftImportResult.Invalid("Trop de brouillons IA.")
val db = database.writableDatabase
@@ -1847,6 +1858,31 @@ class TrainlogRepository(
skipped++
}
}
+ for (withdrawalIndex in 0 until withdrawals.length()) {
+ val withdrawal = withdrawals.opt(withdrawalIndex) as? JSONObject
+ ?: throw IllegalArgumentException("Retrait de proposition IA invalide.")
+ if (!withdrawal.hasExactKeys(setOf("draft_id", "withdrawn_at"))) {
+ throw IllegalArgumentException("Champs de retrait IA invalides.")
+ }
+ val draftId = withdrawal.value("draft_id") as? String
+ ?: throw IllegalArgumentException("draft_id de retrait invalide.")
+ val withdrawnAt = withdrawal.value("withdrawn_at") as? String
+ ?: throw IllegalArgumentException("withdrawn_at invalide.")
+ if (!draftId.matches(AI_DRAFT_ID_V4_PATTERN) || !isUtcTimestamp(withdrawnAt)) {
+ throw IllegalArgumentException("Retrait de proposition IA invalide.")
+ }
+ db.execSQL(
+ "INSERT OR IGNORE INTO ai_session_drafts(" +
+ "draft_id,created_at,state,state_changed_at) VALUES(?,?,'deleted',?);",
+ arrayOf(draftId, withdrawnAt, withdrawnAt),
+ )
+ db.execSQL(
+ "UPDATE ai_session_drafts SET state='deleted',state_changed_at=? " +
+ "WHERE draft_id=? AND state='pending';",
+ arrayOf(withdrawnAt, draftId),
+ )
+ db.delete("ai_session_draft_entries", "draft_id=?", arrayOf(draftId))
+ }
db.setTransactionSuccessful()
AiSessionDraftImportResult.Applied(imported, skipped)
} catch (error: IllegalArgumentException) {
diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt
index ec2a019..1d1d252 100644
--- a/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt
+++ b/android/app/src/test/java/com/labfytools/trainlog/data/AiSessionDraftRepositoryTest.kt
@@ -62,6 +62,41 @@ class AiSessionDraftRepositoryTest {
assertTrue(repository.listSessions().isEmpty())
}
+ @Test
+ fun desktopWithdrawalTombstonePreventsLegacyReplay() {
+ val exercise = exercise()
+ val legacyArtifact = artifact(exercise.exerciseId)
+ assertEquals(
+ AiSessionDraftImportResult.Applied(1, 0),
+ repository.applyAiSessionDraftsJson(legacyArtifact),
+ )
+ val withdrawalArtifact = JSONObject()
+ .put("format", "trainlog-ai-session-drafts")
+ .put("version", 2)
+ .put("generated_at", "2026-09-18T12:01:00Z")
+ .put("drafts", JSONArray())
+ .put(
+ "withdrawals",
+ JSONArray().put(
+ JSONObject()
+ .put("draft_id", DRAFT_ID)
+ .put("withdrawn_at", "2026-09-18T12:00:00Z"),
+ ),
+ )
+ .toString()
+
+ assertTrue(
+ repository.applyAiSessionDraftsJson(withdrawalArtifact) is
+ AiSessionDraftImportResult.Applied,
+ )
+ assertTrue(repository.listAiSessionDrafts().isEmpty())
+ assertEquals(
+ AiSessionDraftImportResult.Applied(0, 1),
+ repository.applyAiSessionDraftsJson(legacyArtifact),
+ )
+ assertTrue(repository.listAiSessionDrafts().isEmpty())
+ }
+
@Test
fun pendingDraftIsListed() {
val exercise = exercise()
diff --git a/tests/test_ai_session_draft.py b/tests/test_ai_session_draft.py
index d9afe61..54fc493 100644
--- a/tests/test_ai_session_draft.py
+++ b/tests/test_ai_session_draft.py
@@ -36,7 +36,7 @@ PRAGMA foreign_keys=ON;
CREATE TABLE exercises(id INTEGER PRIMARY KEY,exercise_id TEXT UNIQUE,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER NOT NULL DEFAULT 0);
CREATE TABLE exercise_aliases(source_exercise_id TEXT PRIMARY KEY,canonical_exercise_id TEXT);
CREATE TABLE performed_sets(id INTEGER PRIMARY KEY);
-CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT);
+CREATE TABLE ai_session_drafts(id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,created_at TEXT NOT NULL,planned_for TEXT,session_type TEXT NOT NULL,title TEXT,notes TEXT,archive_status TEXT NOT NULL DEFAULT 'pending',archived_at TEXT,archive_error TEXT,published_at TEXT,withdrawn_at TEXT);
CREATE TABLE ai_session_draft_entries(id INTEGER PRIMARY KEY,draft_row_id INTEGER NOT NULL REFERENCES ai_session_drafts(id) ON DELETE CASCADE,entry_id TEXT NOT NULL UNIQUE,position INTEGER NOT NULL,exercise_row_id INTEGER NOT NULL REFERENCES exercises(id),source_exercise_id TEXT NOT NULL,recording_mode TEXT NOT NULL,tracking_mode TEXT NOT NULL,data_fields INTEGER NOT NULL,equipment_id TEXT,load_mode TEXT NOT NULL,target_sets INTEGER NOT NULL,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,rest_seconds INTEGER NOT NULL,UNIQUE(draft_row_id,position));
CREATE TABLE ai_session_draft_imports(draft_row_id INTEGER PRIMARY KEY,draft_id TEXT NOT NULL UNIQUE,payload_sha256 TEXT NOT NULL UNIQUE,imported_at TEXT NOT NULL);
CREATE TRIGGER ai_session_draft_import_identity_guard BEFORE DELETE ON ai_session_drafts WHEN EXISTS(SELECT 1 FROM ai_session_draft_imports i WHERE i.draft_id=OLD.draft_id) BEGIN SELECT RAISE(ABORT,'AI draft import identity is permanent');END;
@@ -94,8 +94,13 @@ class DraftTests(unittest.TestCase):
with self.assertRaisesRegex(ImportFailure, "conflit"):
import_payload(con, changed)
exported = build_export(con, "2026-09-14T09:02:00Z")
- self.assertEqual({"format", "version", "generated_at", "drafts"}, set(exported))
+ self.assertEqual(
+ {"format", "version", "generated_at", "drafts", "withdrawals"},
+ set(exported),
+ )
self.assertEqual("trainlog-ai-session-drafts", exported["format"])
+ self.assertEqual(2, exported["version"])
+ self.assertEqual([], exported["withdrawals"])
outbound = exported["drafts"][0]["entries"][0]
self.assertEqual(EX_REPS, outbound["exercise_id"])
self.assertEqual(3, outbound["data_fields"])
@@ -112,6 +117,24 @@ class DraftTests(unittest.TestCase):
"PRAGMA table_info(ai_session_drafts)")})
con.close()
+ def test_withdrawn_proposal_is_exported_only_as_a_tombstone(self):
+ con = self.connect()
+ self.assertEqual("imported", import_payload(con, artifact()))
+ con.execute(
+ "UPDATE ai_session_drafts SET withdrawn_at=? WHERE draft_id=?",
+ ("2026-09-18T12:00:00Z", DRAFT_ID),
+ )
+ con.commit()
+
+ exported = build_export(con, "2026-09-18T12:01:00Z")
+
+ self.assertEqual([], exported["drafts"])
+ self.assertEqual(
+ [{"draft_id": DRAFT_ID, "withdrawn_at": "2026-09-18T12:00:00Z"}],
+ exported["withdrawals"],
+ )
+ con.close()
+
def test_target_sets_99_and_bounded_publication_lifecycle(self):
value = artifact()
value["draft"]["entries"][0]["target_sets"] = 99
diff --git a/tests/test_web_sessions_browser.py b/tests/test_web_sessions_browser.py
index b88c4a4..da30ac8 100644
--- a/tests/test_web_sessions_browser.py
+++ b/tests/test_web_sessions_browser.py
@@ -123,6 +123,15 @@ class BrowserSessionsTest(unittest.TestCase):
str(EVIDENCE / "web-sessions-list-desktop.png")
))
+ driver.find_element(By.XPATH, "//button[normalize-space()='Programmes']").click()
+ wait.until(lambda current: "Importer un programme" in current.page_source)
+ set_viewport(390, 844)
+ self.assertTrue(driver.save_screenshot(
+ str(EVIDENCE / "web-programs-empty-mobile.png")
+ ))
+ driver.find_element(By.XPATH, "//button[normalize-space()='Préparation']").click()
+ wait.until(lambda current: "Nouvelle séance" in current.page_source)
+
driver.find_element(By.XPATH, "//button[normalize-space()='Nouvelle séance']").click()
wait.until(lambda current: "Nouvelle séance" in current.page_source)
title = driver.find_element(By.XPATH, "//label[normalize-space()='Titre']/input")
diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py
index 7bd9202..c585191 100644
--- a/tools/export_ai_session_drafts.py
+++ b/tools/export_ai_session_drafts.py
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
-"""Export the PC→Android trainlog-ai-session-drafts v1 companion."""
+"""Export the PC→Android trainlog-ai-session-drafts v2 companion."""
from __future__ import annotations
@@ -28,6 +28,9 @@ def ensure_publication_state(connection):
if "published_at" not in columns:
connection.execute(
"ALTER TABLE ai_session_drafts ADD COLUMN published_at TEXT")
+ if "withdrawn_at" not in columns:
+ connection.execute(
+ "ALTER TABLE ai_session_drafts ADD COLUMN withdrawn_at TEXT")
# INVARIANT: publication never weakens the permanent Drive replay ledger,
# including on an older v18 database whose FK was declared cascading.
connection.execute(
@@ -45,7 +48,7 @@ def build_export(connection, generated_at):
drafts = []
rows = connection.execute(
"SELECT id,draft_id,created_at,planned_for,session_type,title,notes "
- "FROM ai_session_drafts WHERE published_at IS NULL "
+ "FROM ai_session_drafts WHERE published_at IS NULL AND withdrawn_at IS NULL "
"ORDER BY created_at COLLATE BINARY,draft_id COLLATE BINARY LIMIT ?",
(MAX_DRAFTS,),
).fetchall()
@@ -72,8 +75,22 @@ def build_export(connection, generated_at):
})
# CONTRACT: this companion is separate from every mobile-export version.
# INVARIANT: archive bookkeeping is desktop-private and is never published.
- return {"format": "trainlog-ai-session-drafts", "version": 1,
- "generated_at": generated_at, "drafts": drafts}
+ withdrawals = [
+ {"draft_id": row[0], "withdrawn_at": row[1]}
+ for row in connection.execute(
+ "SELECT draft_id,withdrawn_at FROM ai_session_drafts "
+ "WHERE withdrawn_at IS NOT NULL ORDER BY withdrawn_at,draft_id "
+ "LIMIT ?",
+ (MAX_DRAFTS,),
+ )
+ ]
+ return {
+ "format": "trainlog-ai-session-drafts",
+ "version": 2,
+ "generated_at": generated_at,
+ "drafts": drafts,
+ "withdrawals": withdrawals,
+ }
def mark_published(connection, payload, published_at):
@@ -84,9 +101,17 @@ def mark_published(connection, payload, published_at):
retry exports the same deterministic IDs and the import ledger is retained.
"""
ensure_publication_state(connection)
- if (not isinstance(payload, dict) or set(payload) != {"format", "version", "generated_at", "drafts"}
- or payload["format"] != "trainlog-ai-session-drafts" or payload["version"] != 1
- or not isinstance(payload["drafts"], list) or len(payload["drafts"]) > MAX_DRAFTS):
+ expected_keys = {"format", "version", "generated_at", "drafts", "withdrawals"}
+ if (
+ not isinstance(payload, dict)
+ or set(payload) != expected_keys
+ or payload["format"] != "trainlog-ai-session-drafts"
+ or payload["version"] != 2
+ or not isinstance(payload["drafts"], list)
+ or len(payload["drafts"]) > MAX_DRAFTS
+ or not isinstance(payload["withdrawals"], list)
+ or len(payload["withdrawals"]) > MAX_DRAFTS
+ ):
raise ValueError("lot de brouillons IA publié invalide")
draft_ids = []
for draft in payload["drafts"]:
diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py
index 0d7d811..e68f326 100644
--- a/tools/package_sync_candidate.py
+++ b/tools/package_sync_candidate.py
@@ -96,7 +96,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.2",
- "desktop_schema": 24,
+ "desktop_schema": 25,
"android_schema": 23,
"protocols": [
"mobile-export-v3",
@@ -107,6 +107,7 @@ def main() -> int:
"generation-ack-v1",
"generation-archive-v1",
"session-preparations-v2",
+ "ai-session-drafts-v2",
],
"entry_points": [
"bin/trainlog",
diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py
index 20731da..14fa5c3 100644
--- a/tools/sync_generation_exchange.py
+++ b/tools/sync_generation_exchange.py
@@ -68,7 +68,7 @@ ARTIFACTS = (
"export_training_feedback.py", ()),
("causal-deletions", "trainlog-causal-deletions", 1, "causal-deletions-v1.json", True,
"causal_delete_exchange.py", ("export",)),
- ("ai-proposals", "trainlog-ai-session-drafts", 1, "ai-proposals-v1.json", False,
+ ("ai-proposals", "trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", False,
"export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "export_session_preparations.py", ()),
diff --git a/tui/include/trainlog/id.h b/tui/include/trainlog/id.h
index 10bcf54..0fa0668 100644
--- a/tui/include/trainlog/id.h
+++ b/tui/include/trainlog/id.h
@@ -42,7 +42,8 @@
* - `sxe` for persisted session-exercise occurrences;
* - `sp`, `spr`, `spe`, `spd`, and `spw` for preparation, revision,
* occurrence, delivery, and withdrawal identities;
- * - `pgr` for desktop Program V1 revision identities.
+ * - `pgr` for desktop Program V1 revision identities;
+ * - `del` for immutable causal deletion operations.
*
* Imported v1 documents may contain other schema-valid opaque identifiers;
* this API defines creation policy, not import validation.
diff --git a/tui/include/trainlog/web_session_deletions.h b/tui/include/trainlog/web_session_deletions.h
new file mode 100644
index 0000000..4876d7a
--- /dev/null
+++ b/tui/include/trainlog/web_session_deletions.h
@@ -0,0 +1,27 @@
+/* Trainlog Web Sessions deletion command service. */
+#ifndef TRAINLOG_WEB_SESSION_DELETIONS_H
+#define TRAINLOG_WEB_SESSION_DELETIONS_H
+
+#include
+
+#include "trainlog/database.h"
+#include "trainlog/status.h"
+
+/*
+ * CONTRACT: kind is proposal, draft, or history. Preparation withdrawal keeps
+ * its separate delivery-aware service. expected_revision and request_id are
+ * required bounded strings. Exact request replay returns the original JSON.
+ * No performed session is deleted by proposal or draft removal.
+ *
+ * On success output_json transfers one heap allocation to the caller, which
+ * must release it with free(). No allocation is returned on failure.
+ */
+TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
+ const char *kind,
+ const char *target_id,
+ const char *expected_revision,
+ const char *request_id,
+ char **output_json,
+ size_t *output_size);
+
+#endif
diff --git a/tui/meson.build b/tui/meson.build
index d76c9a8..e4778a6 100644
--- a/tui/meson.build
+++ b/tui/meson.build
@@ -57,6 +57,7 @@ if web_assets_enabled
'../web/src/app/useRoute.ts',
'../web/src/components/EmptyState.tsx',
'../web/src/components/DisplayPreferences.tsx',
+ '../web/src/components/DeleteConfirmationDialog.tsx',
'../web/src/components/Footer.tsx',
'../web/src/components/Header.tsx',
'../web/src/components/SyncControl.tsx',
@@ -205,6 +206,7 @@ trainlog_core_sources = files(
'src/web_prepared_items.c',
'src/web_prepared_items_json.c',
'src/web_sessions.c',
+ 'src/web_session_deletions.c',
'src/web_programs.c',
'src/duration.c',
'src/id.c',
@@ -379,6 +381,15 @@ test_web_programs = executable(
)
test('web_programs', test_web_programs)
+test_web_session_deletions = executable(
+ 'test_web_session_deletions',
+ 'tests/test_web_session_deletions.c',
+ dependencies: trainlog_core_dep,
+ include_directories: trainlog_private_include,
+ c_args: strict_c_args,
+)
+test('web_session_deletions', test_web_session_deletions)
+
test_dashboard_header = executable(
'test_dashboard_header',
'tests/test_dashboard_header.c',
diff --git a/tui/src/database.c b/tui/src/database.c
index 77030c0..bddd1ad 100644
--- a/tui/src/database.c
+++ b/tui/src/database.c
@@ -1208,6 +1208,10 @@ static const char *const MIGRATE_V24_TO_V25_SQL =
"CREATE TABLE IF NOT EXISTS program_requests("
"request_id TEXT PRIMARY KEY,command TEXT NOT NULL,program_id TEXT NOT NULL,"
"response_json TEXT NOT NULL,created_at TEXT NOT NULL);"
+ "CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
+ "request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,target_id TEXT NOT NULL,"
+ "expected_revision TEXT NOT NULL,response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
+ "UNIQUE(resource_kind,target_id));"
"PRAGMA user_version=25;COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while
@@ -1251,6 +1255,43 @@ static TrainlogStatus ensure_v25_preparation_provenance(TrainlogDatabase *databa
return TRAINLOG_STATUS_OK;
}
+static TrainlogStatus ensure_v25_proposal_withdrawal(TrainlogDatabase *database) {
+ sqlite3_stmt *statement = NULL;
+ bool found = false;
+ int result;
+
+ result = sqlite3_prepare_v2(
+ database->connection, "PRAGMA table_info(ai_session_drafts);", -1, &statement, NULL);
+ if (result != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
+ const char *name = (const char *)sqlite3_column_text(statement, 1);
+
+ if (name != NULL && strcmp(name, "withdrawn_at") == 0) {
+ found = true;
+ }
+ }
+ if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ if (!found &&
+ execute_sql(database, "ALTER TABLE ai_session_drafts ADD COLUMN withdrawn_at TEXT;") !=
+ TRAINLOG_STATUS_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ return TRAINLOG_STATUS_OK;
+}
+
+static TrainlogStatus ensure_v25_web_deletion_requests(TrainlogDatabase *database) {
+ return execute_sql(database,
+ "CREATE TABLE IF NOT EXISTS web_session_deletion_requests("
+ "request_id TEXT PRIMARY KEY,resource_kind TEXT NOT NULL,"
+ "target_id TEXT NOT NULL,expected_revision TEXT NOT NULL,"
+ "response_json TEXT NOT NULL,created_at TEXT NOT NULL,"
+ "UNIQUE(resource_kind,target_id));");
+}
+
/* CONTRACT: v18 was already deployed before bounded draft publication was
* added. Keep its version number and add only the nullable lifecycle cursor;
* NULL deliberately means retryable/not yet published. */
@@ -2088,6 +2129,12 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v25_preparation_provenance(database);
}
+ if (status == TRAINLOG_STATUS_OK) {
+ status = ensure_v25_proposal_withdrawal(database);
+ }
+ if (status == TRAINLOG_STATUS_OK) {
+ status = ensure_v25_web_deletion_requests(database);
+ }
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
diff --git a/tui/src/id.c b/tui/src/id.c
index 2078c3f..b657005 100644
--- a/tui/src/id.c
+++ b/tui/src/id.c
@@ -20,7 +20,7 @@ static bool prefix_is_supported(const char *prefix) {
return strcmp(prefix, "ex") == 0 || strcmp(prefix, "se") == 0 || strcmp(prefix, "bo") == 0 ||
strcmp(prefix, "sy") == 0 || strcmp(prefix, "sxe") == 0 || strcmp(prefix, "sp") == 0 ||
strcmp(prefix, "spr") == 0 || strcmp(prefix, "spe") == 0 || strcmp(prefix, "spd") == 0 ||
- strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0;
+ strcmp(prefix, "spw") == 0 || strcmp(prefix, "pgr") == 0 || strcmp(prefix, "del") == 0;
}
TrainlogStatus trainlog_id_generate(const char *prefix, char *output, size_t output_size) {
diff --git a/tui/src/web_prepared_items.c b/tui/src/web_prepared_items.c
index 90bc967..cc9e08d 100644
--- a/tui/src/web_prepared_items.c
+++ b/tui/src/web_prepared_items.c
@@ -51,13 +51,14 @@ static bool format_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
}
-static TrainlogStatus load_ai_proposals(
- TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
+static TrainlogStatus load_ai_proposals(TrainlogDatabase *database,
+ TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.draft_id,COALESCE(d.title,''),COALESCE(d.planned_for,''),"
"COUNT(e.id),CASE WHEN d.published_at IS NULL THEN 'pending_publication' ELSE 'published' "
"END,d.created_at FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON "
- "e.draft_row_id=d.id GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),"
+ "e.draft_row_id=d.id WHERE d.withdrawn_at IS NULL GROUP BY d.id ORDER BY "
+ "COALESCE(d.planned_for,'9999-12-31'),"
"d.created_at,d.draft_id LIMIT ?1;";
sqlite3_stmt *statement = NULL;
int step;
@@ -99,8 +100,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
-static TrainlogStatus load_manual_preparations(
- TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
+static TrainlogStatus load_manual_preparations(TrainlogDatabase *database,
+ TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT p.preparation_id,r.title,COALESCE(r.planned_for,''),COUNT(e.entry_id),"
"p.editing_state,p.updated_at FROM session_preparations p JOIN "
@@ -148,8 +149,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
-static TrainlogStatus load_execution_drafts(
- TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
+static TrainlogStatus load_execution_drafts(TrainlogDatabase *database,
+ TrainlogWebPreparedItems *output) {
static const char SQL[] =
"SELECT d.session_id,d.state,d.session_type,COALESCE(substr(d.started_at,1,10),''),"
"COALESCE(json_array_length(d.payload_json,'$.exercises'),0),COALESCE(d.started_at,'') "
@@ -201,8 +202,8 @@ fail:
return TRAINLOG_STATUS_DATABASE_ERROR;
}
-TrainlogStatus trainlog_web_prepared_items_load(
- TrainlogDatabase *database, TrainlogWebPreparedItems *output) {
+TrainlogStatus trainlog_web_prepared_items_load(TrainlogDatabase *database,
+ TrainlogWebPreparedItems *output) {
TrainlogStatus status;
TrainlogStatus end_status;
diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c
index e4072e6..4d0fb4b 100644
--- a/tui/src/web_programs.c
+++ b/tui/src/web_programs.c
@@ -1,5 +1,6 @@
#include "trainlog/web_programs.h"
+#include
#include
#include
#include
@@ -468,6 +469,44 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
return write_document(document, output_json, output_size);
}
+static bool equipment_is_known(TrainlogDatabase *database, yyjson_val *equipment) {
+ sqlite3_stmt *statement = NULL;
+ int result;
+
+ if (yyjson_is_null(equipment)) {
+ return true;
+ }
+ result = sqlite3_prepare_v2(
+ database->connection,
+ "SELECT 1 FROM custom_equipment e WHERE e.equipment_id=?1 AND NOT EXISTS("
+ "SELECT 1 FROM sync_causal_state c WHERE c.target_kind='custom_equipment' "
+ "AND c.target_id=e.equipment_id AND c.deleted=1)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, yyjson_get_str(equipment), -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ (void)sqlite3_finalize(statement);
+ return result == SQLITE_ROW;
+}
+
+static bool valid_target_weight(const char *load_mode, yyjson_val *weight) {
+ double target_weight;
+
+ if (strcmp(load_mode, "none") == 0) {
+ return yyjson_is_null(weight);
+ }
+ if (!yyjson_is_num(weight)) {
+ return false;
+ }
+ target_weight = yyjson_get_num(weight);
+ return isfinite(target_weight) && target_weight > 0.0 && target_weight <= 2000.0;
+}
+
static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence) {
static const char *const KEYS[] = {"entry_id",
"exercise_id",
@@ -487,6 +526,8 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
int prepare_result;
int bind_result;
int step_result;
+ yyjson_val *equipment;
+ yyjson_val *weight;
if (!object_has_exact_keys(occurrence, KEYS, sizeof(KEYS) / sizeof(KEYS[0])) ||
!bounded_string(yyjson_obj_get(occurrence, "entry_id"), 128U, false) ||
@@ -507,6 +548,11 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
strcmp(load_mode, "assistance") != 0) {
return false;
}
+ equipment = yyjson_obj_get(occurrence, "equipment_id");
+ weight = yyjson_obj_get(occurrence, "target_weight_kg");
+ if (!equipment_is_known(database, equipment) || !valid_target_weight(load_mode, weight)) {
+ return false;
+ }
prepare_result =
sqlite3_prepare_v2(database->connection,
"SELECT recording_mode,tracking_mode "
@@ -552,10 +598,12 @@ static bool valid_occurrence(TrainlogDatabase *database, yyjson_val *occurrence)
if (strcmp(tracking_mode, "reps") == 0) {
yyjson_val *target_reps = yyjson_obj_get(occurrence, "target_reps");
return yyjson_is_int(target_reps) && yyjson_get_sint(target_reps) >= 1 &&
+ yyjson_get_sint(target_reps) <= 10000 &&
yyjson_is_null(yyjson_obj_get(occurrence, "target_duration_seconds"));
}
return yyjson_is_int(yyjson_obj_get(occurrence, "target_duration_seconds")) &&
yyjson_get_sint(yyjson_obj_get(occurrence, "target_duration_seconds")) >= 1 &&
+ yyjson_get_sint(yyjson_obj_get(occurrence, "target_duration_seconds")) <= 604800 &&
yyjson_is_null(yyjson_obj_get(occurrence, "target_reps"));
}
@@ -955,24 +1003,42 @@ static TrainlogStatus program_persist_import(TrainlogDatabase *database,
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
-static TrainlogStatus program_build_import_response(const char *program_id,
+static TrainlogStatus program_build_import_response(yyjson_val *program,
const char *digest,
- size_t session_count,
bool imported,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *response = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = response == NULL ? NULL : yyjson_mut_obj(response);
+ yyjson_mut_val *warnings = response == NULL ? NULL : yyjson_mut_arr(response);
+ yyjson_val *start_date = yyjson_obj_get(program, "start_date");
+ yyjson_val *end_date = yyjson_obj_get(program, "end_date");
- if (response == NULL || root == NULL) {
+ if (response == NULL || root == NULL || warnings == NULL) {
yyjson_mut_doc_free(response);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(response, root);
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
- (void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
+ (void)yyjson_mut_obj_add_strcpy(
+ response, root, "program_id", yyjson_get_str(yyjson_obj_get(program, "program_id")));
+ (void)yyjson_mut_obj_add_strcpy(
+ response, root, "title", yyjson_get_str(yyjson_obj_get(program, "title")));
+ if (yyjson_is_null(start_date)) {
+ (void)yyjson_mut_obj_add_null(response, root, "start_date");
+ } else {
+ (void)yyjson_mut_obj_add_strcpy(response, root, "start_date", yyjson_get_str(start_date));
+ }
+ if (yyjson_is_null(end_date)) {
+ (void)yyjson_mut_obj_add_null(response, root, "end_date");
+ } else {
+ (void)yyjson_mut_obj_add_strcpy(response, root, "end_date", yyjson_get_str(end_date));
+ }
(void)yyjson_mut_obj_add_strcpy(response, root, "payload_sha256", digest);
- (void)yyjson_mut_obj_add_uint(response, root, "session_count", session_count);
+ (void)yyjson_mut_obj_add_uint(
+ response, root, "session_count", yyjson_arr_size(yyjson_obj_get(program, "sessions")));
+ (void)yyjson_mut_obj_add_uint(response, root, "unknown_exercise_count", 0U);
+ (void)yyjson_mut_obj_add_val(response, root, "warnings", warnings);
(void)yyjson_mut_obj_add_bool(response, root, "imported", imported);
return write_document(response, output_json, output_size);
}
@@ -1018,12 +1084,7 @@ TrainlogStatus trainlog_web_programs_import_json(TrainlogDatabase *database,
imported = status == TRAINLOG_STATUS_OK;
}
if (status == TRAINLOG_STATUS_OK) {
- status = program_build_import_response(program_id,
- digest,
- yyjson_arr_size(yyjson_obj_get(program, "sessions")),
- imported,
- output_json,
- output_size);
+ status = program_build_import_response(program, digest, imported, output_json, output_size);
}
yyjson_doc_free(input);
return status;
diff --git a/tui/src/web_server.c b/tui/src/web_server.c
index 5557f61..341d271 100644
--- a/tui/src/web_server.c
+++ b/tui/src/web_server.c
@@ -23,6 +23,7 @@
#include "trainlog/web_dashboard.h"
#include "trainlog/web_prepared_items.h"
#include "trainlog/web_programs.h"
+#include "trainlog/web_session_deletions.h"
#include "trainlog/web_sessions.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_preferences.h"
@@ -793,6 +794,77 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
return queue_owned_programs_json(connection, status, json, json_size);
}
+static enum MHD_Result queue_session_deletion_result(struct MHD_Connection *connection,
+ TrainlogStatus status,
+ char *json,
+ size_t json_size) {
+ if (status == TRAINLOG_STATUS_CONFLICT) {
+ free(json);
+ return queue_json(
+ connection, MHD_HTTP_CONFLICT, "{\"error\":\"deletion_conflict\"}\n", NULL);
+ }
+ if (status == TRAINLOG_STATUS_NOT_FOUND) {
+ free(json);
+ return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
+ }
+ if (status == TRAINLOG_STATUS_INVALID_ARGUMENT) {
+ free(json);
+ return queue_json(
+ connection, MHD_HTTP_UNPROCESSABLE_CONTENT, "{\"error\":\"invalid_deletion\"}\n", NULL);
+ }
+ return queue_owned_sessions_json(connection, status, json, json_size);
+}
+
+static enum MHD_Result handle_session_deletion(TrainlogWebContext *context,
+ struct MHD_Connection *connection,
+ const char *url,
+ const char *method) {
+ static const char PREFIX[] = "/api/v1/sessions/";
+ const char *request_id =
+ MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
+ const char *if_match =
+ MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
+ const char *kind = url + strlen(PREFIX);
+ const char *separator = strchr(kind, '/');
+ char kind_buffer[32];
+ char identity[TRAINLOG_ID_MAX + 1U];
+ char revision[TRAINLOG_ID_MAX + 1U];
+ char *json = NULL;
+ size_t json_size = 0U;
+ TrainlogStatus status;
+
+ if (strcmp(method, MHD_HTTP_METHOD_DELETE) != 0) {
+ return queue_json(connection,
+ MHD_HTTP_METHOD_NOT_ALLOWED,
+ "{\"error\":\"method_not_allowed\"}\n",
+ "DELETE");
+ }
+ if (separator == NULL || separator[1] == '\0' ||
+ !copy_path_component(kind_buffer, sizeof(kind_buffer), kind, (size_t)(separator - kind)) ||
+ !copy_path_component(identity, sizeof(identity), separator + 1, strlen(separator + 1))) {
+ return queue_json(
+ connection, MHD_HTTP_BAD_REQUEST, "{\"error\":\"invalid_identity\"}\n", NULL);
+ }
+ if (strcmp(kind_buffer, "proposal") != 0 && strcmp(kind_buffer, "draft") != 0 &&
+ strcmp(kind_buffer, "history") != 0) {
+ return queue_json(connection, MHD_HTTP_NOT_FOUND, "{\"error\":\"not_found\"}\n", NULL);
+ }
+ if (!program_mutation_allowed(context, connection) || request_id == NULL ||
+ request_id[0] == '\0' || strlen(request_id) > 128U) {
+ return queue_json(
+ connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
+ }
+ if (!parse_quoted_revision(if_match, revision, sizeof(revision))) {
+ return queue_json(connection,
+ MHD_HTTP_PRECONDITION_REQUIRED,
+ "{\"error\":\"precondition_required\"}\n",
+ NULL);
+ }
+ status = trainlog_web_session_delete_json(
+ context->database, kind_buffer, identity, revision, request_id, &json, &json_size);
+ return queue_session_deletion_result(connection, status, json, json_size);
+}
+
static enum MHD_Result handle_request(void *closure,
struct MHD_Connection *connection,
const char *url,
@@ -1033,6 +1105,13 @@ static enum MHD_Result handle_request(void *closure,
size_t limit;
size_t index;
+ if (strcmp(method, MHD_HTTP_METHOD_DELETE) == 0 &&
+ strncmp(url,
+ "/api/v1/sessions/preparation/",
+ strlen("/api/v1/sessions/preparation/")) != 0) {
+ return handle_session_deletion(context, connection, url, method);
+ }
+
if (!is_get && (strcmp(url, "/api/v1/sessions/preparations") == 0 ||
strncmp(url,
"/api/v1/sessions/preparation/",
diff --git a/tui/src/web_session_deletions.c b/tui/src/web_session_deletions.c
new file mode 100644
index 0000000..8383b3c
--- /dev/null
+++ b/tui/src/web_session_deletions.c
@@ -0,0 +1,600 @@
+#include "trainlog/web_session_deletions.h"
+
+#include
+#include
+#include
+#include
+#include
+
+#include
+#include
+#include
+
+#include "database_internal.h"
+#include "trainlog/id.h"
+#include "trainlog/model.h"
+
+#define CAUSAL_OPERATION_MAX 4096
+#define CAUSAL_TEXT_BUDGET (3 * 1024 * 1024)
+#define PROPOSAL_WITHDRAWAL_MAX 256
+
+typedef struct DeleteContext {
+ const char *kind;
+ const char *target_id;
+ const char *expected_revision;
+ const char *request_id;
+ char created_at[TRAINLOG_TIMESTAMP_MAX + 1U];
+} DeleteContext;
+
+static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) {
+ time_t now = time(NULL);
+ struct tm utc;
+
+ return now != (time_t)-1 && gmtime_r(&now, &utc) != NULL &&
+ strftime(output, TRAINLOG_TIMESTAMP_MAX + 1U, "%Y-%m-%dT%H:%M:%SZ", &utc) > 0U;
+}
+
+static bool copy_column(sqlite3_stmt *statement, int column, char **output, size_t *output_size) {
+ const char *value = (const char *)sqlite3_column_text(statement, column);
+ int bytes = sqlite3_column_bytes(statement, column);
+
+ if (value == NULL || bytes <= 0) {
+ return false;
+ }
+ *output = malloc((size_t)bytes + 1U);
+ if (*output == NULL) {
+ return false;
+ }
+ (void)memcpy(*output, value, (size_t)bytes);
+ (*output)[bytes] = '\0';
+ *output_size = (size_t)bytes;
+ return true;
+}
+
+static TrainlogStatus replay_request(TrainlogDatabase *database,
+ const DeleteContext *context,
+ char **output_json,
+ size_t *output_size,
+ bool *replayed) {
+ static const char SQL[] =
+ "SELECT resource_kind,target_id,expected_revision,response_json "
+ "FROM web_session_deletion_requests WHERE request_id=?1 OR "
+ "(resource_kind=?2 AND target_id=?3) ORDER BY request_id=?1 DESC LIMIT 1";
+ sqlite3_stmt *statement = NULL;
+ int result;
+
+ *replayed = false;
+ result = sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (result == SQLITE_DONE) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_OK;
+ }
+ if (result != SQLITE_ROW) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ if (strcmp((const char *)sqlite3_column_text(statement, 0), context->kind) != 0 ||
+ strcmp((const char *)sqlite3_column_text(statement, 1), context->target_id) != 0 ||
+ strcmp((const char *)sqlite3_column_text(statement, 2), context->expected_revision) != 0) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_CONFLICT;
+ }
+ if (!copy_column(statement, 3, output_json, output_size)) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ if (sqlite3_finalize(statement) != SQLITE_OK) {
+ free(*output_json);
+ *output_json = NULL;
+ *output_size = 0U;
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ *replayed = true;
+ return TRAINLOG_STATUS_OK;
+}
+
+static TrainlogStatus load_current_revision(TrainlogDatabase *database,
+ const char *kind,
+ const char *target_id,
+ char revision[TRAINLOG_ID_MAX + 1U],
+ bool *active_draft) {
+ static const char DRAFT_SQL[] =
+ "SELECT revision_id,state FROM execution_drafts WHERE session_id=?1 AND NOT EXISTS("
+ "SELECT 1 FROM sync_causal_state WHERE target_kind='execution_draft' AND target_id=?1 "
+ "AND deleted=1)";
+ static const char HISTORY_SQL[] =
+ "SELECT c.current_revision_id,'' FROM sessions s JOIN sync_causal_state c ON "
+ "c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=0 "
+ "WHERE s.session_id=?1";
+ sqlite3_stmt *statement = NULL;
+ const char *sql = strcmp(kind, "draft") == 0 ? DRAFT_SQL : HISTORY_SQL;
+ const char *current;
+ int result;
+
+ *active_draft = false;
+ result = sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (result != SQLITE_ROW) {
+ (void)sqlite3_finalize(statement);
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ current = (const char *)sqlite3_column_text(statement, 0);
+ if (current == NULL || strlen(current) > TRAINLOG_ID_MAX) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ (void)snprintf(revision, TRAINLOG_ID_MAX + 1U, "%s", current);
+ *active_draft = strcmp((const char *)sqlite3_column_text(statement, 1), "active") == 0;
+ return sqlite3_finalize(statement) == SQLITE_OK ? TRAINLOG_STATUS_OK
+ : TRAINLOG_STATUS_DATABASE_ERROR;
+}
+
+static bool sha256_hex(const char *input, char output[65]) {
+ unsigned char digest[32];
+ unsigned int digest_size = 0U;
+ EVP_MD_CTX *context = EVP_MD_CTX_new();
+ size_t index;
+ bool success;
+
+ if (context == NULL) {
+ return false;
+ }
+ success = EVP_DigestInit_ex(context, EVP_sha256(), NULL) == 1 &&
+ EVP_DigestUpdate(context, input, strlen(input)) == 1 &&
+ EVP_DigestFinal_ex(context, digest, &digest_size) == 1 && digest_size == 32U;
+ EVP_MD_CTX_free(context);
+ if (!success) {
+ return false;
+ }
+ for (index = 0U; index < 32U; ++index) {
+ (void)snprintf(output + (index * 2U), 3U, "%02x", digest[index]);
+ }
+ output[64] = '\0';
+ return true;
+}
+
+static TrainlogStatus causal_capacity_available(TrainlogDatabase *database) {
+ sqlite3_stmt *statement = NULL;
+ sqlite3_int64 count;
+ sqlite3_int64 bytes;
+ int result;
+
+ result = sqlite3_prepare_v2(
+ database->connection,
+ "SELECT COUNT(*),COALESCE(SUM(length(operation_id)+length(target_kind)+"
+ "length(target_id)+length(creator_id)+length(predecessor_revision_id)+"
+ "length(created_at)+length(payload_sha256)+128),0) FROM sync_causal_operations",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (result != SQLITE_ROW) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ count = sqlite3_column_int64(statement, 0);
+ bytes = sqlite3_column_int64(statement, 1);
+ if (sqlite3_finalize(statement) != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ return count < CAUSAL_OPERATION_MAX && bytes < CAUSAL_TEXT_BUDGET ? TRAINLOG_STATUS_OK
+ : TRAINLOG_STATUS_CONFLICT;
+}
+
+static TrainlogStatus insert_causal_operation(TrainlogDatabase *database,
+ const DeleteContext *context,
+ const char *causal_kind,
+ const char *predecessor_revision,
+ const char *operation_id) {
+ static const char CREATOR[] = "peer_desktop_web";
+ sqlite3_stmt *statement = NULL;
+ char payload[1024];
+ char digest[65];
+ int written;
+ int result;
+
+ written = snprintf(payload,
+ sizeof(payload),
+ "{\"created_at\":\"%s\",\"creator_id\":\"%s\","
+ "\"operation_id\":\"%s\",\"predecessor_revision_id\":\"%s\","
+ "\"publication_context\":null,\"target_id\":\"%s\","
+ "\"target_kind\":\"%s\"}",
+ context->created_at,
+ CREATOR,
+ operation_id,
+ predecessor_revision,
+ context->target_id,
+ causal_kind);
+ if (written < 0 || (size_t)written >= sizeof(payload) || !sha256_hex(payload, digest)) {
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ result =
+ sqlite3_prepare_v2(database->connection,
+ "INSERT INTO sync_causal_operations VALUES(?1,?2,?3,?4,?5,?6,?7,NULL)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, operation_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, causal_kind, -1, SQLITE_STATIC);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 4, CREATOR, -1, SQLITE_STATIC);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 5, predecessor_revision, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 7, digest, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
+}
+
+static TrainlogStatus apply_causal_effect(TrainlogDatabase *database,
+ const DeleteContext *context,
+ const char *causal_kind,
+ const char *operation_id) {
+ sqlite3_stmt *statement = NULL;
+ int result;
+
+ if (strcmp(context->kind, "history") == 0) {
+ result = sqlite3_prepare_v2(
+ database->connection,
+ "UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id "
+ "FROM sessions WHERE session_id=?1)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
+ result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ statement = NULL;
+ if (result != SQLITE_DONE) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ }
+ result = sqlite3_prepare_v2(database->connection,
+ strcmp(context->kind, "draft") == 0
+ ? "DELETE FROM execution_drafts WHERE session_id=?1"
+ : "DELETE FROM sessions WHERE session_id=?1",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ statement = NULL;
+ if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ if (strcmp(context->kind, "history") == 0) {
+ char final_revision[TRAINLOG_GENERATED_ID_CAPACITY + sizeof("deleted:")];
+ int written = snprintf(final_revision, sizeof(final_revision), "deleted:%s", operation_id);
+
+ if (written < 0 || (size_t)written >= sizeof(final_revision)) {
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ result = sqlite3_prepare_v2(
+ database->connection,
+ "INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?1,?2,?3)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, final_revision, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 3, context->created_at, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK &&
+ result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ statement = NULL;
+ if (result != SQLITE_DONE) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ }
+ result = sqlite3_prepare_v2(database->connection,
+ "INSERT OR REPLACE INTO sync_causal_state VALUES(?1,?2,?3,1,?3)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, causal_kind, -1, SQLITE_STATIC);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 3, operation_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
+}
+
+static TrainlogStatus delete_causal_resource(TrainlogDatabase *database,
+ const DeleteContext *context) {
+ const char *causal_kind = strcmp(context->kind, "draft") == 0 ? "execution_draft" : "session";
+ char current_revision[TRAINLOG_ID_MAX + 1U];
+ char operation_id[TRAINLOG_GENERATED_ID_CAPACITY];
+ bool active_draft;
+ TrainlogStatus status;
+
+ status = load_current_revision(
+ database, context->kind, context->target_id, current_revision, &active_draft);
+ if (status != TRAINLOG_STATUS_OK) {
+ return status;
+ }
+ if (active_draft || strcmp(current_revision, context->expected_revision) != 0) {
+ return TRAINLOG_STATUS_CONFLICT;
+ }
+ status = causal_capacity_available(database);
+ if (status != TRAINLOG_STATUS_OK) {
+ return status;
+ }
+ if (trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK) {
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ status =
+ insert_causal_operation(database, context, causal_kind, current_revision, operation_id);
+ if (status == TRAINLOG_STATUS_OK) {
+ status = apply_causal_effect(database, context, causal_kind, operation_id);
+ }
+ return status;
+}
+
+static TrainlogStatus delete_proposal(TrainlogDatabase *database, const DeleteContext *context) {
+ sqlite3_stmt *statement = NULL;
+ const char *revision;
+ int result;
+
+ result = sqlite3_prepare_v2(
+ database->connection,
+ "SELECT COALESCE(i.payload_sha256,d.draft_id) FROM ai_session_drafts d LEFT JOIN "
+ "ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
+ "d.withdrawn_at IS NULL",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (result != SQLITE_ROW) {
+ (void)sqlite3_finalize(statement);
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_NOT_FOUND : TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ revision = (const char *)sqlite3_column_text(statement, 0);
+ if (revision == NULL || strcmp(revision, context->expected_revision) != 0) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_CONFLICT;
+ }
+ if (sqlite3_finalize(statement) != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ statement = NULL;
+ result = sqlite3_prepare_v2(database->connection,
+ "SELECT COUNT(*) FROM ai_session_drafts "
+ "WHERE withdrawn_at IS NOT NULL",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (result != SQLITE_ROW) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ if (sqlite3_column_int64(statement, 0) >= PROPOSAL_WITHDRAWAL_MAX) {
+ (void)sqlite3_finalize(statement);
+ return TRAINLOG_STATUS_CONFLICT;
+ }
+ if (sqlite3_finalize(statement) != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ statement = NULL;
+ result = sqlite3_prepare_v2(database->connection,
+ "UPDATE ai_session_drafts SET withdrawn_at=?1 WHERE draft_id=?2 "
+ "AND withdrawn_at IS NULL",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->created_at, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ if (result != SQLITE_DONE || sqlite3_changes(database->connection) != 1) {
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_CONFLICT : TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ return TRAINLOG_STATUS_OK;
+}
+
+static TrainlogStatus
+build_response(const DeleteContext *context, char **output_json, size_t *output_size) {
+ yyjson_mut_doc *document = yyjson_mut_doc_new(NULL);
+ yyjson_mut_val *root = document == NULL ? NULL : yyjson_mut_obj(document);
+
+ if (document == NULL || root == NULL) {
+ yyjson_mut_doc_free(document);
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ yyjson_mut_doc_set_root(document, root);
+ if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) ||
+ !yyjson_mut_obj_add_strcpy(document, root, "kind", context->kind) ||
+ !yyjson_mut_obj_add_strcpy(document, root, "identity", context->target_id) ||
+ !yyjson_mut_obj_add_strcpy(document, root, "state", "deleted") ||
+ !yyjson_mut_obj_add_strcpy(document, root, "deleted_at", context->created_at)) {
+ yyjson_mut_doc_free(document);
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ *output_json = yyjson_mut_write(document, 0U, output_size);
+ yyjson_mut_doc_free(document);
+ return *output_json != NULL ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_SYSTEM_ERROR;
+}
+
+static TrainlogStatus
+store_request(TrainlogDatabase *database, const DeleteContext *context, const char *response_json) {
+ sqlite3_stmt *statement = NULL;
+ int result;
+
+ result = sqlite3_prepare_v2(database->connection,
+ "INSERT INTO web_session_deletion_requests "
+ "VALUES(?1,?2,?3,?4,?5,?6)",
+ -1,
+ &statement,
+ NULL);
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 1, context->request_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 2, context->kind, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 3, context->target_id, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 4, context->expected_revision, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 5, response_json, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_bind_text(statement, 6, context->created_at, -1, SQLITE_TRANSIENT);
+ }
+ if (result == SQLITE_OK) {
+ result = sqlite3_step(statement);
+ }
+ if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) {
+ result = SQLITE_ERROR;
+ }
+ return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR;
+}
+
+TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database,
+ const char *kind,
+ const char *target_id,
+ const char *expected_revision,
+ const char *request_id,
+ char **output_json,
+ size_t *output_size) {
+ DeleteContext context;
+ TrainlogStatus status;
+ bool replayed;
+
+ if (database == NULL || kind == NULL || target_id == NULL || expected_revision == NULL ||
+ request_id == NULL || output_json == NULL || output_size == NULL || target_id[0] == '\0' ||
+ expected_revision[0] == '\0' || request_id[0] == '\0' ||
+ strlen(target_id) > TRAINLOG_ID_MAX || strlen(expected_revision) > TRAINLOG_ID_MAX ||
+ strlen(request_id) > 128U ||
+ (strcmp(kind, "proposal") != 0 && strcmp(kind, "draft") != 0 &&
+ strcmp(kind, "history") != 0)) {
+ return TRAINLOG_STATUS_INVALID_ARGUMENT;
+ }
+ *output_json = NULL;
+ *output_size = 0U;
+ context.kind = kind;
+ context.target_id = target_id;
+ context.expected_revision = expected_revision;
+ context.request_id = request_id;
+ if (!timestamp_now(context.created_at)) {
+ return TRAINLOG_STATUS_SYSTEM_ERROR;
+ }
+ if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
+ return TRAINLOG_STATUS_DATABASE_ERROR;
+ }
+ status = replay_request(database, &context, output_json, output_size, &replayed);
+ if (status == TRAINLOG_STATUS_OK && replayed) {
+ status = sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK
+ ? TRAINLOG_STATUS_OK
+ : TRAINLOG_STATUS_DATABASE_ERROR;
+ return status;
+ }
+ if (status == TRAINLOG_STATUS_OK) {
+ status = strcmp(kind, "proposal") == 0 ? delete_proposal(database, &context)
+ : delete_causal_resource(database, &context);
+ }
+ if (status == TRAINLOG_STATUS_OK) {
+ status = build_response(&context, output_json, output_size);
+ }
+ if (status == TRAINLOG_STATUS_OK) {
+ status = store_request(database, &context, *output_json);
+ }
+ if (status == TRAINLOG_STATUS_OK &&
+ sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) {
+ return TRAINLOG_STATUS_OK;
+ }
+ (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
+ free(*output_json);
+ *output_json = NULL;
+ *output_size = 0U;
+ return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
+}
diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c
index 9af978b..22548b0 100644
--- a/tui/src/web_sessions.c
+++ b/tui/src/web_sessions.c
@@ -33,7 +33,8 @@ static const ListDefinition LISTS[] = {
"SELECT d.draft_id,COALESCE(d.title,''),d.planned_for,CASE WHEN d.published_at IS NULL THEN "
"'local' ELSE 'published' END,COUNT(e.entry_id),d.created_at "
"FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id "
- "WHERE (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM "
+ "WHERE d.withdrawn_at IS NULL AND (?1='' OR d.title LIKE '%'||?1||'%' COLLATE NOCASE OR "
+ "EXISTS(SELECT 1 FROM "
"ai_session_draft_entries ae JOIN exercises x ON x.id=ae.exercise_row_id WHERE "
"ae.draft_row_id=d.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) "
"GROUP BY d.id ORDER BY COALESCE(d.planned_for,'9999-12-31'),d.created_at,d.draft_id LIMIT ?2 "
@@ -198,9 +199,11 @@ static TrainlogStatus detail_for_revision(TrainlogDatabase *database,
const char *sql =
proposal ? "SELECT "
"d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes,"
- "i.payload_sha256,d.draft_id,CASE WHEN d.published_at IS NULL THEN "
+ "i.payload_sha256,COALESCE(i.payload_sha256,d.draft_id),CASE WHEN "
+ "d.published_at IS NULL THEN "
"'local' ELSE 'published' END FROM ai_session_drafts d LEFT JOIN "
- "ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1"
+ "ai_session_draft_imports i ON i.draft_row_id=d.id WHERE d.draft_id=?1 AND "
+ "d.withdrawn_at IS NULL"
: "SELECT "
"p.preparation_id,r.title,r.planned_for,r.session_type,r.notes,"
"COALESCE(p.source_payload_sha256,''),p.current_revision_id,"
@@ -339,8 +342,11 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
const char *identity,
yyjson_mut_doc *document,
yyjson_mut_val *root) {
- static const char SESSION_SQL[] = "SELECT session_id,session_type,started_at,ended_at,notes "
- "FROM sessions WHERE session_id=?1";
+ static const char SESSION_SQL[] =
+ "SELECT s.session_id,s.session_type,s.started_at,s.ended_at,s.notes,"
+ "COALESCE(c.current_revision_id,'') FROM sessions s LEFT JOIN sync_causal_state c ON "
+ "c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=0 "
+ "WHERE s.session_id=?1";
static const char ENTRY_SQL[] =
"SELECT "
"se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se."
@@ -369,7 +375,8 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database,
!add_nullable_text(document, root, "session_type", statement, 1) ||
!add_nullable_text(document, root, "started_at", statement, 2) ||
!add_nullable_text(document, root, "ended_at", statement, 3) ||
- !add_nullable_text(document, root, "notes", statement, 4)) {
+ !add_nullable_text(document, root, "notes", statement, 4) ||
+ !add_nullable_text(document, root, "revision_id", statement, 5)) {
(void)sqlite3_finalize(statement);
return TRAINLOG_STATUS_DATABASE_ERROR;
}
diff --git a/tui/tests/test_schema_v21_migration.c b/tui/tests/test_schema_v21_migration.c
index e49e244..45e8e2e 100644
--- a/tui/tests/test_schema_v21_migration.c
+++ b/tui/tests/test_schema_v21_migration.c
@@ -29,6 +29,7 @@ static int scalar(sqlite3 *database, const char *sql) {
int main(void) {
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
+ "DROP TABLE web_session_deletion_requests;"
"DROP TABLE program_requests;"
"DROP TABLE program_session_entries;"
"DROP TABLE program_sessions;"
@@ -78,6 +79,10 @@ int main(void) {
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw, "SELECT COUNT(*) FROM session_preparation_withdrawals") == 0);
+ CHECK(scalar(raw, "SELECT COUNT(*) FROM web_session_deletion_requests") == 0);
+ CHECK(scalar(raw,
+ "SELECT COUNT(*) FROM pragma_table_info('ai_session_drafts') "
+ "WHERE name='withdrawn_at'") == 1);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_generations") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_consumed_generations") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sync_acknowledgements") == 0);
diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c
index f0dc9cf..b05f1a6 100644
--- a/tui/tests/test_web_programs.c
+++ b/tui/tests/test_web_programs.c
@@ -71,12 +71,126 @@ extract_string(const char *json, const char *key, char *output, size_t output_ca
return true;
}
+static char *replace_once(const char *source, const char *needle, const char *replacement) {
+ const char *match = strstr(source, needle);
+ size_t prefix_size;
+ size_t output_size;
+ char *output;
+
+ if (match == NULL) {
+ return NULL;
+ }
+ prefix_size = (size_t)(match - source);
+ output_size = strlen(source) - strlen(needle) + strlen(replacement) + 1U;
+ output = malloc(output_size);
+ if (output == NULL) {
+ return NULL;
+ }
+ (void)snprintf(output,
+ output_size,
+ "%.*s%s%s",
+ (int)prefix_size,
+ source,
+ replacement,
+ match + strlen(needle));
+ return output;
+}
+
+static bool strict_import_rejections(void) {
+ TrainlogDatabase *database = NULL;
+ char *response = NULL;
+ size_t response_size = 0U;
+ char *unknown_exercise;
+ char *unknown_equipment;
+ char *performed_data;
+ char *divergent;
+ char *oversized;
+
+ CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
+ CHECK(trainlog_database_insert_exercise_profiled(database,
+ "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ "Exercise fixture",
+ "exercise fixture",
+ TRAINLOG_TRACKING_REPS,
+ TRAINLOG_RECORDING_SETS,
+ 0U) == TRAINLOG_STATUS_OK);
+ CHECK(trainlog_web_programs_import_json(database, "{", 1U, true, &response, &response_size) ==
+ TRAINLOG_STATUS_INVALID_ARGUMENT);
+ CHECK(trainlog_web_programs_import_json(
+ database,
+ "{\"format\":\"trainlog-program\",\"format\":\"trainlog-program\","
+ "\"version\":1,\"program\":{}}",
+ strlen("{\"format\":\"trainlog-program\",\"format\":\"trainlog-program\","
+ "\"version\":1,\"program\":{}}"),
+ true,
+ &response,
+ &response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
+
+ unknown_exercise = replace_once(PROGRAM_JSON,
+ "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
+ "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee");
+ unknown_equipment =
+ replace_once(PROGRAM_JSON, "\"equipment_id\":null", "\"equipment_id\":\"missing\"");
+ performed_data = replace_once(PROGRAM_JSON, "\"notes\":null", "\"notes\":null,\"sets\":[]");
+ divergent = replace_once(PROGRAM_JSON, "Cycle lisible", "Cycle divergent");
+ CHECK(unknown_exercise != NULL && unknown_equipment != NULL && performed_data != NULL &&
+ divergent != NULL);
+ CHECK(trainlog_web_programs_import_json(database,
+ unknown_exercise,
+ strlen(unknown_exercise),
+ true,
+ &response,
+ &response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
+ CHECK(trainlog_web_programs_import_json(database,
+ unknown_equipment,
+ strlen(unknown_equipment),
+ true,
+ &response,
+ &response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
+ CHECK(trainlog_web_programs_import_json(
+ database, performed_data, strlen(performed_data), true, &response, &response_size) ==
+ TRAINLOG_STATUS_INVALID_ARGUMENT);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 0);
+
+ CHECK(trainlog_web_programs_import_json(
+ database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
+ TRAINLOG_STATUS_OK);
+ free(response);
+ response = NULL;
+ CHECK(trainlog_web_programs_import_json(
+ database, divergent, strlen(divergent), true, &response, &response_size) ==
+ TRAINLOG_STATUS_CONFLICT);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 1);
+
+ oversized = malloc(TRAINLOG_WEB_PROGRAM_BYTES_MAX + 2U);
+ CHECK(oversized != NULL);
+ (void)memset(oversized, ' ', TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U);
+ oversized[TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U] = '\0';
+ CHECK(trainlog_web_programs_import_json(database,
+ oversized,
+ TRAINLOG_WEB_PROGRAM_BYTES_MAX + 1U,
+ true,
+ &response,
+ &response_size) == TRAINLOG_STATUS_INVALID_ARGUMENT);
+
+ free(oversized);
+ free(divergent);
+ free(performed_data);
+ free(unknown_equipment);
+ free(unknown_exercise);
+ trainlog_database_close(database);
+ return true;
+}
+
static bool import_archive_and_prepare(void) {
TrainlogDatabase *database = NULL;
char *response = NULL;
size_t response_size = 0U;
char revision[128];
char preparation_id[128];
+ char *second_program;
+ char *second_session;
+ char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_insert_exercise_profiled(database,
@@ -113,6 +227,35 @@ static bool import_archive_and_prepare(void) {
free(response);
response = NULL;
+ second_program = replace_once(PROGRAM_JSON,
+ "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
+ "pg_11111111-1111-4111-8111-111111111111");
+ CHECK(second_program != NULL);
+ second_session = replace_once(second_program,
+ "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
+ "pgs_22222222-2222-4222-8222-222222222222");
+ free(second_program);
+ CHECK(second_session != NULL);
+ second_entry = replace_once(second_session,
+ "pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
+ "pge_33333333-3333-4333-8333-333333333333");
+ free(second_session);
+ CHECK(second_entry != NULL);
+ CHECK(trainlog_web_programs_import_json(
+ database, second_entry, strlen(second_entry), true, &response, &response_size) ==
+ TRAINLOG_STATUS_OK);
+ free(second_entry);
+ free(response);
+ response = NULL;
+ CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 2);
+ CHECK(trainlog_web_programs_list_json(
+ database, "Cycle", "active", 0U, 1U, &response, &response_size) ==
+ TRAINLOG_STATUS_OK);
+ CHECK(strstr(response, "\"more\":true") != NULL);
+ CHECK(strstr(response, "\"next_offset\":1") != NULL);
+ free(response);
+ response = NULL;
+
CHECK(trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
TRAINLOG_STATUS_OK);
@@ -157,5 +300,5 @@ static bool import_archive_and_prepare(void) {
}
int main(void) {
- return import_archive_and_prepare() ? EXIT_SUCCESS : EXIT_FAILURE;
+ return import_archive_and_prepare() && strict_import_rejections() ? EXIT_SUCCESS : EXIT_FAILURE;
}
diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c
index dc86dfa..229c911 100644
--- a/tui/tests/test_web_server.c
+++ b/tui/tests/test_web_server.c
@@ -15,6 +15,7 @@
#include
+#include "database_internal.h"
#include "trainlog/database.h"
#include "trainlog/dashboard_layout.h"
#include "trainlog/web_server.h"
@@ -235,6 +236,18 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(json_string(created, "preparation_id", preparation_id, sizeof(preparation_id)));
CHECK(json_string(created, "revision_id", revision_id, sizeof(revision_id)));
free(created);
+ CHECK(sqlite3_exec(database->connection,
+ "INSERT INTO ai_session_drafts("
+ "draft_id,created_at,session_type,title,archive_status) VALUES("
+ "'aid_66666666-6666-4666-8666-666666666666',"
+ "'2026-09-18T10:00:00Z','training','Proposal HTTP','archived');"
+ "INSERT INTO ai_session_draft_imports VALUES("
+ "last_insert_rowid(),'aid_66666666-6666-4666-8666-666666666666',"
+ "'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',"
+ "'2026-09-18T10:00:00Z');",
+ NULL,
+ NULL,
+ NULL) == SQLITE_OK);
(void)unlink("/tmp/trainlog-web-test.db.sync-run.json");
CHECK(reserve_port(&port, 0) == -2);
child = fork();
@@ -307,18 +320,17 @@ static bool test_http_contract(TrainlogDatabase *database) {
revision_id);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 403") != NULL);
- (void)snprintf(
- request,
- sizeof(request),
- "DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
- "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
- "Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
- "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
- "Content-Length: 2\r\n\r\n{}",
- preparation_id,
- (unsigned int)port,
- revision_id,
- token);
+ (void)snprintf(request,
+ sizeof(request),
+ "DELETE /api/v1/sessions/preparation/%s HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
+ "Content-Type: application/json\r\nIf-Match: \"%s\"\r\n"
+ "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-delete\r\n"
+ "Content-Length: 2\r\n\r\n{}",
+ preparation_id,
+ (unsigned int)port,
+ revision_id,
+ token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"android_cancellation\":\"not_required\"") != NULL);
@@ -330,6 +342,42 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"state\":\"withdrawn\"") != NULL);
+ (void)snprintf(request,
+ sizeof(request),
+ "DELETE /api/v1/sessions/proposal/"
+ "aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
+ "Content-Type: application/json\r\nIf-Match: \"stale\"\r\n"
+ "X-Trainlog-CSRF-Token: %s\r\n"
+ "X-Trainlog-Request-ID: request-http-delete-proposal-stale\r\n"
+ "Content-Length: 2\r\n\r\n{}",
+ (unsigned int)port,
+ token);
+ CHECK(exchange(port, request, response, sizeof(response)) &&
+ strstr(response, "HTTP/1.1 409") != NULL);
+ (void)snprintf(
+ request,
+ sizeof(request),
+ "DELETE /api/v1/sessions/proposal/"
+ "aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n"
+ "Content-Type: application/json\r\n"
+ "If-Match: \"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb\"\r\n"
+ "X-Trainlog-CSRF-Token: %s\r\n"
+ "X-Trainlog-Request-ID: request-http-delete-proposal\r\n"
+ "Content-Length: 2\r\n\r\n{}",
+ (unsigned int)port,
+ token);
+ CHECK(exchange(port, request, response, sizeof(response)) &&
+ strstr(response, "HTTP/1.1 200") != NULL &&
+ strstr(response, "\"state\":\"deleted\"") != NULL);
+ CHECK(exchange(port,
+ "GET /api/v1/sessions/proposal/"
+ "aid_66666666-6666-4666-8666-666666666666 HTTP/1.1\r\n"
+ "Host: 127.0.0.1\r\n\r\n",
+ response,
+ sizeof(response)) &&
+ strstr(response, "HTTP/1.1 404") != NULL);
}
CHECK(exchange(port,
"GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n",
@@ -362,16 +410,15 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(response_header(response, "ETag: ", etag, sizeof(etag)) &&
strcmp(etag, "\"0\"") == 0);
CHECK(response_header(response, "X-Trainlog-CSRF-Token: ", token, sizeof(token)));
- (void)snprintf(
- request,
- sizeof(request),
- "PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
- "Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
- "If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
- "{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
- "\"date_format\":\"iso\"}",
- (unsigned int)port,
- token);
+ (void)snprintf(request,
+ sizeof(request),
+ "PUT /api/v1/web-preferences HTTP/1.1\r\nHost: 127.0.0.1\r\n"
+ "Origin: http://127.0.0.1:%u\r\nContent-Type: application/json\r\n"
+ "If-Match: \"0\"\r\nX-Trainlog-CSRF-Token: %s\r\nContent-Length: 82\r\n\r\n"
+ "{\"format\":\"trainlog-web-preferences\",\"version\":1,\"revision\":0,"
+ "\"date_format\":\"iso\"}",
+ (unsigned int)port,
+ token);
CHECK(exchange(port, request, response, sizeof(response)) &&
strstr(response, "HTTP/1.1 200") != NULL &&
strstr(response, "\"revision\":1") != NULL &&
@@ -390,10 +437,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
const char *config = getenv("XDG_CONFIG_HOME");
CHECK(config != NULL);
- CHECK(snprintf(path,
- sizeof(path),
- "%s/trainlog/web/preferences-v1.json",
- config) > 0);
+ CHECK(snprintf(path, sizeof(path), "%s/trainlog/web/preferences-v1.json", config) > 0);
stream = fopen(path, "w");
CHECK(stream != NULL);
CHECK(fputs("invalid preference\n", stream) >= 0);
diff --git a/tui/tests/test_web_session_deletions.c b/tui/tests/test_web_session_deletions.c
new file mode 100644
index 0000000..c409d4b
--- /dev/null
+++ b/tui/tests/test_web_session_deletions.c
@@ -0,0 +1,185 @@
+#include
+#include
+#include
+#include
+#include
+
+#include
+
+#include "database_internal.h"
+#include "trainlog/database.h"
+#include "trainlog/status.h"
+#include "trainlog/web_session_deletions.h"
+
+#define CHECK(condition) \
+ do { \
+ if (!(condition)) { \
+ (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #condition); \
+ return false; \
+ } \
+ } while (0)
+
+static sqlite3_int64 scalar(TrainlogDatabase *database, const char *sql) {
+ sqlite3_stmt *statement = NULL;
+ sqlite3_int64 value = -1;
+
+ if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) == SQLITE_OK &&
+ sqlite3_step(statement) == SQLITE_ROW) {
+ value = sqlite3_column_int64(statement, 0);
+ }
+ (void)sqlite3_finalize(statement);
+ return value;
+}
+
+static bool proposal_deletion_is_durable_and_preserves_derivatives(void) {
+ char path[] = "/tmp/trainlog-web-delete-proposal-XXXXXX";
+ TrainlogDatabase *database = NULL;
+ char *response = NULL;
+ char *replayed = NULL;
+ size_t response_size = 0U;
+ size_t replayed_size = 0U;
+ int descriptor = mkstemp(path);
+
+ CHECK(descriptor >= 0);
+ CHECK(close(descriptor) == 0);
+ CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
+ CHECK(sqlite3_exec(database->connection,
+ "INSERT INTO ai_session_drafts("
+ "draft_id,created_at,session_type,title,archive_status,published_at) "
+ "VALUES('aid_11111111-1111-4111-8111-111111111111',"
+ "'2026-09-18T10:00:00Z','training','Proposal','archived',"
+ "'2026-09-18T11:00:00Z');"
+ "INSERT INTO ai_session_draft_imports VALUES("
+ "last_insert_rowid(),'aid_11111111-1111-4111-8111-111111111111',"
+ "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
+ "'2026-09-18T10:00:00Z');"
+ "INSERT INTO session_preparations VALUES("
+ "'sp_22222222-2222-4222-8222-222222222222','spr_source',"
+ "'2026-09-18T10:00:00Z','2026-09-18T10:00:00Z','draft','local',"
+ "'aid_11111111-1111-4111-8111-111111111111',"
+ "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',"
+ "NULL,NULL,NULL);",
+ NULL,
+ NULL,
+ NULL) == SQLITE_OK);
+ CHECK(trainlog_web_session_delete_json(
+ database,
+ "proposal",
+ "aid_11111111-1111-4111-8111-111111111111",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "request-delete-proposal",
+ &response,
+ &response_size) == TRAINLOG_STATUS_OK);
+ CHECK(response != NULL && strstr(response, "\"state\":\"deleted\"") != NULL);
+ CHECK(trainlog_web_session_delete_json(
+ database,
+ "proposal",
+ "aid_11111111-1111-4111-8111-111111111111",
+ "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
+ "request-delete-proposal",
+ &replayed,
+ &replayed_size) == TRAINLOG_STATUS_OK);
+ CHECK(response_size == replayed_size && strcmp(response, replayed) == 0);
+ CHECK(scalar(database,
+ "SELECT COUNT(*) FROM ai_session_drafts WHERE withdrawn_at IS NOT NULL") == 1);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM web_session_deletion_requests") == 1);
+ free(replayed);
+ free(response);
+ trainlog_database_close(database);
+ CHECK(unlink(path) == 0);
+ return true;
+}
+
+static bool draft_and_history_use_causal_deletion(void) {
+ char path[] = "/tmp/trainlog-web-delete-causal-XXXXXX";
+ TrainlogDatabase *database = NULL;
+ char *response = NULL;
+ size_t response_size = 0U;
+ int descriptor = mkstemp(path);
+
+ CHECK(descriptor >= 0);
+ CHECK(close(descriptor) == 0);
+ CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
+ CHECK(sqlite3_exec(database->connection,
+ "INSERT INTO execution_drafts VALUES("
+ "'se_33333333-3333-4333-8333-333333333333','training',NULL,"
+ "'2026-09-18T10:00:00Z','mu_draft',NULL,'pending','{}');"
+ "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES("
+ "'se_44444444-4444-4444-8444-444444444444',"
+ "'2026-09-17T10:00:00Z','2026-09-17T11:00:00Z','training');"
+ "INSERT INTO sync_causal_state VALUES("
+ "'session','se_44444444-4444-4444-8444-444444444444',"
+ "'mu_history',0,NULL);",
+ NULL,
+ NULL,
+ NULL) == SQLITE_OK);
+ CHECK(trainlog_web_session_delete_json(database,
+ "draft",
+ "se_33333333-3333-4333-8333-333333333333",
+ "mu_draft",
+ "request-delete-draft",
+ &response,
+ &response_size) == TRAINLOG_STATUS_OK);
+ free(response);
+ response = NULL;
+ CHECK(trainlog_web_session_delete_json(database,
+ "history",
+ "se_44444444-4444-4444-8444-444444444444",
+ "mu_history",
+ "request-delete-history",
+ &response,
+ &response_size) == TRAINLOG_STATUS_OK);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 0);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM sessions") == 0);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 2);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1") == 2);
+ CHECK(scalar(database,
+ "SELECT COUNT(*) FROM execution_draft_finalizations "
+ "WHERE session_id='se_44444444-4444-4444-8444-444444444444' "
+ "AND final_revision_id LIKE 'deleted:del_%'") == 1);
+ free(response);
+ trainlog_database_close(database);
+ CHECK(unlink(path) == 0);
+ return true;
+}
+
+static bool active_draft_conflicts_without_mutation(void) {
+ char path[] = "/tmp/trainlog-web-delete-active-XXXXXX";
+ TrainlogDatabase *database = NULL;
+ char *response = NULL;
+ size_t response_size = 0U;
+ int descriptor = mkstemp(path);
+
+ CHECK(descriptor >= 0);
+ CHECK(close(descriptor) == 0);
+ CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK);
+ CHECK(sqlite3_exec(database->connection,
+ "INSERT INTO execution_drafts VALUES("
+ "'se_55555555-5555-4555-8555-555555555555','training',NULL,"
+ "'2026-09-18T10:00:00Z','mu_active',NULL,'active','{}');",
+ NULL,
+ NULL,
+ NULL) == SQLITE_OK);
+ CHECK(trainlog_web_session_delete_json(database,
+ "draft",
+ "se_55555555-5555-4555-8555-555555555555",
+ "mu_active",
+ "request-delete-active",
+ &response,
+ &response_size) == TRAINLOG_STATUS_CONFLICT);
+ CHECK(response == NULL && response_size == 0U);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM execution_drafts") == 1);
+ CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0);
+ trainlog_database_close(database);
+ CHECK(unlink(path) == 0);
+ return true;
+}
+
+int main(void) {
+ return proposal_deletion_is_durable_and_preserves_derivatives() &&
+ draft_and_history_use_causal_deletion() &&
+ active_draft_conflicts_without_mutation()
+ ? EXIT_SUCCESS
+ : EXIT_FAILURE;
+}
diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts
index 6fd1d73..f99b5fa 100644
--- a/web/src/api/programs.ts
+++ b/web/src/api/programs.ts
@@ -58,11 +58,32 @@ export interface ProgramDetail {
export interface ProgramImportPreview {
api_version: 1
program_id: string
+ title: string
+ start_date: string | null
+ end_date: string | null
payload_sha256: string
session_count: number
+ unknown_exercise_count: number
+ warnings: string[]
imported: boolean
}
+function programImportPreview(value: unknown): ProgramImportPreview {
+ if (!object(value) || value.api_version !== 1 ||
+ typeof value.program_id !== 'string' || typeof value.title !== 'string' ||
+ (value.start_date !== null && typeof value.start_date !== 'string') ||
+ (value.end_date !== null && typeof value.end_date !== 'string') ||
+ typeof value.payload_sha256 !== 'string' ||
+ !Number.isSafeInteger(value.session_count) ||
+ !Number.isSafeInteger(value.unknown_exercise_count) ||
+ !Array.isArray(value.warnings) ||
+ !value.warnings.every((warning) => typeof warning === 'string') ||
+ typeof value.imported !== 'boolean') {
+ throw new TypeError('aperçu de programme invalide')
+ }
+ return value as unknown as ProgramImportPreview
+}
+
function object(value: unknown): value is Record {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@@ -152,7 +173,7 @@ export async function validateProgramImport(body: string): Promise
+ return programImportPreview(await response.json())
}
export async function importProgram(body: string): Promise {
@@ -160,7 +181,7 @@ export async function importProgram(body: string): Promise
if (!response.ok) {
throw new Error(await errorReason(response))
}
- return response.json() as Promise
+ return programImportPreview(await response.json())
}
export async function archiveProgram(programId: string, revision: string): Promise {
diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts
index f036fbd..b966dcc 100644
--- a/web/src/api/sessions.ts
+++ b/web/src/api/sessions.ts
@@ -264,3 +264,34 @@ export async function withdrawPreparation(
}
return value as unknown as PreparationWithdrawalResult
}
+
+export type DeletableSessionKind = 'proposal' | 'draft' | 'history'
+
+export async function deleteSessionResource(
+ kind: DeletableSessionKind,
+ identity: string,
+ revision: string,
+): Promise {
+ const csrf = await mutationCsrfToken()
+ const response = await fetch(
+ `/api/v1/sessions/${kind}/${encodeURIComponent(identity)}`,
+ {
+ method: 'DELETE',
+ headers: {
+ Accept: 'application/json',
+ 'Content-Type': 'application/json',
+ 'X-Trainlog-CSRF-Token': csrf,
+ 'X-Trainlog-Request-ID': requestId(),
+ 'If-Match': `"${revision}"`,
+ },
+ body: '{}',
+ },
+ )
+ const value: unknown = await response.json()
+ if (!response.ok) {
+ const reason = object(value) && typeof value.error === 'string'
+ ? value.error
+ : `HTTP ${response.status}`
+ throw new Error(reason)
+ }
+}
diff --git a/web/src/components/DeleteConfirmationDialog.tsx b/web/src/components/DeleteConfirmationDialog.tsx
new file mode 100644
index 0000000..534d480
--- /dev/null
+++ b/web/src/components/DeleteConfirmationDialog.tsx
@@ -0,0 +1,79 @@
+import { useEffect, useRef, type RefObject } from 'react'
+
+interface DeleteConfirmationDialogProps {
+ title: string
+ itemType: string
+ date?: string | null
+ consequence: string
+ cancelLabel: string
+ confirmLabel: string
+ busy: boolean
+ error: string
+ returnFocus: RefObject
+ onCancel: () => void
+ onConfirm: () => void
+}
+
+export function DeleteConfirmationDialog(props: DeleteConfirmationDialogProps) {
+ const dialog = useRef(null)
+ const cancel = useRef(null)
+
+ useEffect(() => {
+ cancel.current?.focus()
+ const returnTarget = props.returnFocus.current
+ return () => returnTarget?.focus()
+ }, [props.returnFocus])
+
+ const handleKeyDown = (event: React.KeyboardEvent) => {
+ if (event.key === 'Escape' && !props.busy) {
+ event.preventDefault()
+ props.onCancel()
+ return
+ }
+ if (event.key !== 'Tab') return
+ const buttons = dialog.current?.querySelectorAll('button:not(:disabled)')
+ if (!buttons || buttons.length === 0) return
+ const first = buttons[0]
+ const last = buttons[buttons.length - 1]
+ if (event.shiftKey && document.activeElement === first) {
+ event.preventDefault()
+ last.focus()
+ } else if (!event.shiftKey && document.activeElement === last) {
+ event.preventDefault()
+ first.focus()
+ }
+ }
+
+ return
+
+
{props.itemType}
+
{props.confirmLabel} « {props.title} » ?
+ {props.date &&
{props.date}
}
+
{props.consequence}
+ {props.error &&
{props.error}
}
+
+
+
+
+
+
+}
diff --git a/web/src/routes/ProgramsTab.test.tsx b/web/src/routes/ProgramsTab.test.tsx
new file mode 100644
index 0000000..0e34d3e
--- /dev/null
+++ b/web/src/routes/ProgramsTab.test.tsx
@@ -0,0 +1,151 @@
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'
+import { describe, expect, it, vi } from 'vitest'
+import { ProgramsTab } from './ProgramsTab'
+
+function jsonResponse(value: unknown, headers?: HeadersInit) {
+ return Promise.resolve({
+ ok: true,
+ status: 200,
+ headers: new Headers(headers),
+ json: () => Promise.resolve(value),
+ })
+}
+
+function programPage() {
+ return {
+ api_version: 1,
+ offset: 0,
+ more: false,
+ next_offset: 0,
+ items: [],
+ }
+}
+
+function preview(imported: boolean) {
+ return {
+ api_version: 1,
+ program_id: 'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ title: 'Cycle test',
+ start_date: '2026-09-21',
+ end_date: null,
+ payload_sha256: 'a'.repeat(64),
+ session_count: 2,
+ unknown_exercise_count: 0,
+ warnings: [],
+ imported,
+ }
+}
+
+describe('Programs tab', () => {
+ it('shows a server preview before the only importing mutation', async () => {
+ const onOpen = vi.fn()
+ const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
+ const url = String(input)
+ if (url.startsWith('/api/v1/sessions/programs?')) {
+ return jsonResponse(programPage())
+ }
+ if (url === '/api/v1/sync/status') {
+ return jsonResponse(
+ { phase: 'idle', result: 'ok' },
+ { 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
+ )
+ }
+ if (url === '/api/v1/sessions/programs/validate') {
+ return jsonResponse(preview(false))
+ }
+ if (url === '/api/v1/sessions/programs/import') {
+ return jsonResponse(preview(true))
+ }
+ throw new Error(`unexpected request ${url}`)
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ render()
+ await waitFor(() => expect(screen.getByText('Aucun programme.')).toBeInTheDocument())
+
+ const input = document.querySelector('input[type="file"]') as HTMLInputElement
+ const file = new File(['{"format":"trainlog-program"}'], 'cycle.json', {
+ type: 'application/json',
+ })
+ fireEvent.change(input, { target: { files: [file] } })
+
+ const previewPanel = await screen.findByRole('region', { name: 'Aperçu avant import' })
+ expect(previewPanel).toHaveTextContent('Cycle test')
+ expect(previewPanel).toHaveTextContent('2 séances')
+ expect(previewPanel).toHaveTextContent('Aucune donnée n’a encore été enregistrée.')
+ expect(fetchMock.mock.calls.filter(([url]) =>
+ String(url) === '/api/v1/sessions/programs/import')).toHaveLength(0)
+
+ fireEvent.click(within(previewPanel).getByRole('button', { name: 'Importer' }))
+ await waitFor(() => expect(onOpen).toHaveBeenCalledWith(
+ 'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',
+ ))
+ expect(fetchMock.mock.calls.filter(([url]) =>
+ String(url) === '/api/v1/sessions/programs/import')).toHaveLength(1)
+ })
+
+ it('creates one preparation from a program session and navigates to it', async () => {
+ window.history.replaceState(null, '', '/seances/program/pg_test')
+ const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {
+ const url = String(input)
+ if (url === '/api/v1/sessions/program/pg_test') {
+ return jsonResponse({
+ api_version: 1,
+ program_id: 'pg_test',
+ title: 'Cycle test',
+ note: null,
+ state: 'active',
+ start_date: null,
+ end_date: null,
+ created_at: '2026-09-18T12:00:00Z',
+ updated_at: '2026-09-18T12:00:00Z',
+ revision_id: 'revision',
+ source_format: 'trainlog-program',
+ source_version: 1,
+ source_payload_sha256: 'a'.repeat(64),
+ sessions: [{
+ program_session_id: 'pgs_test',
+ position: 0,
+ title: 'Séance A',
+ session_type: 'training',
+ planned_for: null,
+ note: null,
+ occurrences: [{
+ entry_id: 'pge_test',
+ position: 0,
+ exercise_id: 'ex_test',
+ equipment_id: null,
+ recording_mode: 'sets',
+ tracking_mode: 'reps',
+ data_fields: 0,
+ load_mode: 'none',
+ rest_seconds: 90,
+ target_sets: 3,
+ target_reps: 8,
+ target_duration_seconds: null,
+ target_weight_kg: null,
+ notes: null,
+ }],
+ }],
+ })
+ }
+ if (url === '/api/v1/sync/status') {
+ return jsonResponse(
+ { phase: 'idle', result: 'ok' },
+ { 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
+ )
+ }
+ if (url.endsWith('/sessions/pgs_test/prepare')) {
+ return jsonResponse({ preparation_id: 'sp_created' })
+ }
+ throw new Error(`unexpected request ${url}`)
+ })
+ vi.stubGlobal('fetch', fetchMock)
+ render()
+
+ fireEvent.click(await screen.findByRole('button', { name: 'Créer une préparation' }))
+
+ await waitFor(() => expect(window.location.pathname).toBe('/seances/preparation/sp_created'))
+ expect(fetchMock.mock.calls.filter(([, options]) =>
+ (options as RequestInit | undefined)?.method === 'POST')).toHaveLength(1)
+ })
+})
diff --git a/web/src/routes/ProgramsTab.tsx b/web/src/routes/ProgramsTab.tsx
index d8bd293..a0ff832 100644
--- a/web/src/routes/ProgramsTab.tsx
+++ b/web/src/routes/ProgramsTab.tsx
@@ -101,6 +101,18 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
{' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''}
+
+ {session.occurrences.map((occurrence) => -
+ {occurrence.exercise_id}
+
+ {occurrence.target_sets ?? '—'} série
+ {occurrence.target_sets === 1 ? '' : 's'} ·{' '}
+ {occurrence.tracking_mode === 'reps'
+ ? `${occurrence.target_reps ?? '—'} répétitions`
+ : `${occurrence.target_duration_seconds ?? '—'} s`}
+
+
)}
+