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) =>
  1. + {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`} + +
  2. )} +
diff --git a/web/src/routes/SessionsPage.test.tsx b/web/src/routes/SessionsPage.test.tsx index f243087..e60275a 100644 --- a/web/src/routes/SessionsPage.test.tsx +++ b/web/src/routes/SessionsPage.test.tsx @@ -45,7 +45,14 @@ describe('Sessions page', () => { })) render() await waitFor(() => expect(screen.getByText('Proposition cible')).toBeInTheDocument()) - expect(screen.getByText('Proposition IA')).toBeInTheDocument() + expect(screen.getByText('PROPOSITION IA')).toBeInTheDocument() + const trash = screen.getByRole('button', { name: 'Supprimer la proposition' }) + fireEvent.click(trash) + expect(window.location.pathname).toBe('/seances') + fireEvent.click(within(screen.getByRole('alertdialog')).getByRole('button', { + name: 'Conserver', + })) + await waitFor(() => expect(trash).toHaveFocus()) fireEvent.click(screen.getByText('Proposition cible')) expect(window.location.pathname).toBe('/seances/proposal/aid_target') await waitFor(() => expect(screen.getByRole('heading', { name: 'Proposition cible' })).toBeInTheDocument()) @@ -153,14 +160,20 @@ describe('Sessions page', () => { const deleteTrigger = screen.getByRole('button', { name: 'Supprimer la préparation' }) fireEvent.click(deleteTrigger) let dialog = screen.getByRole('alertdialog') - expect(dialog).toHaveTextContent('toute exécution commencée sera conservée') - const keepButton = within(dialog).getByRole('button', { name: 'Conserver la préparation' }) + expect(dialog).toHaveTextContent('sans effacer un entraînement réel') + const keepButton = within(dialog).getByRole('button', { name: 'Conserver' }) expect(keepButton).toHaveFocus() fireEvent.click(keepButton) expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() await waitFor(() => expect(deleteTrigger).toHaveFocus()) expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' })) + dialog = screen.getByRole('alertdialog') + fireEvent.keyDown(dialog, { key: 'Escape' }) + expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument() + expect(fetchMock.mock.calls.some((call) => (call[1] as RequestInit | undefined)?.method === 'DELETE')).toBe(false) + fireEvent.click(screen.getByRole('button', { name: 'Supprimer la préparation' })) dialog = screen.getByRole('alertdialog') fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer la préparation' })) diff --git a/web/src/routes/SessionsPage.tsx b/web/src/routes/SessionsPage.tsx index edb6dbb..0b61c45 100644 --- a/web/src/routes/SessionsPage.tsx +++ b/web/src/routes/SessionsPage.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef, useState } from 'react' import { fetchCatalog, + deleteSessionResource, fetchSessionDetail, fetchReadonlySessionDetail, prepareForAndroid, @@ -14,6 +15,7 @@ import { type SessionDetail, type SessionListItem, } from '../api/sessions' +import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog' import { useDatePreferences } from '../presentation/DatePreferences' import { formatCivilDate, @@ -40,6 +42,53 @@ function viewLabel(view: View): string { export type SessionListEntry = SessionListItem & { collection: SessionCollection } +function deletionKind(collection: SessionCollection): 'preparation' | 'proposal' | 'draft' | 'history' { + if (collection === 'preparations') return 'preparation' + if (collection === 'proposals') return 'proposal' + if (collection === 'drafts') return 'draft' + return 'history' +} + +function deletionLabel(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { + const labels = { + preparation: 'Supprimer la préparation', + proposal: 'Supprimer la proposition', + draft: 'Abandonner le brouillon', + history: 'Supprimer la séance de l’historique', + } + return labels[kind] +} + +function deletionType(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { + const labels = { + preparation: 'PRÉPARATION MANUELLE', + proposal: 'PROPOSITION IA', + draft: 'BROUILLON D’EXÉCUTION', + history: 'SÉANCE RÉALISÉE', + } + return labels[kind] +} + +function deletionConsequence(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { + const consequences = { + preparation: 'La préparation sera retirée. Ses livraisons Android non commencées seront annulées, sans effacer un entraînement réel.', + proposal: 'La proposition sera retirée et son retrait sera propagé. Les préparations ou séances déjà dérivées seront conservées.', + draft: 'Le brouillon sera abandonné seulement s’il n’est pas actif ou modifié concurremment sur Android.', + history: 'La séance sera retirée de l’historique avec une protection empêchant sa réapparition depuis un ancien instantané.', + } + return consequences[kind] +} + +function deletionSuccess(kind: 'preparation' | 'proposal' | 'draft' | 'history'): string { + const messages = { + preparation: 'Préparation supprimée.', + proposal: 'Proposition supprimée.', + draft: 'Brouillon abandonné.', + history: 'Séance supprimée de l’historique.', + } + return messages[kind] +} + function compareIdentity(left: SessionListEntry, right: SessionListEntry): number { return `${left.collection}:${left.identity}`.localeCompare( `${right.collection}:${right.identity}`, 'en') @@ -250,8 +299,7 @@ function Editor({ initial, onSaved, onCancel }: { function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'proposal'; identity: string; onBack: () => void; onEdit: (detail: SessionDetail) => void }) { const { dateFormat } = useDatePreferences() - const deleteTrigger = useRef(null) - const cancelDelete = useRef(null) + const deleteTrigger = useRef(null) const [detail, setDetail] = useState(null) const [failed, setFailed] = useState(false) const [confirming, setConfirming] = useState(false) @@ -265,9 +313,6 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro if (!controller.signal.aborted) setFailed(true) }) return () => controller.abort() }, [kind, identity]) - useEffect(() => { - if (confirming) cancelDelete.current?.focus() - }, [confirming]) if (failed) return
Fiche indisponible.
if (!detail) return

Chargement de la fiche…

const withdrawn = detail.state === 'withdrawn' @@ -275,17 +320,20 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro setDeleting(true) setDeleteError('') try { - const result = await withdrawPreparation(detail.identity, detail.revision_id) - setConfirming(false) - setMessage(result.android_cancellation === 'pending' - ? 'Supprimée sur le PC — annulation Android à synchroniser.' - : 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.') - setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id }) - try { - setDetail(await fetchSessionDetail('preparation', detail.identity)) - } catch { - setMessage((current) => `${current} Rechargez la fiche pour obtenir l’état synchronisé.`) + if (kind === 'preparation') { + const result = await withdrawPreparation(detail.identity, detail.revision_id) + setMessage(result.android_cancellation === 'pending' + ? 'Supprimée sur le PC — annulation Android à synchroniser.' + : 'Préparation supprimée sur le PC. Aucune livraison Android à annuler.') + setDetail({ ...detail, state: 'withdrawn', withdrawal_id: result.withdrawal_id }) + } else { + await deleteSessionResource('proposal', detail.identity, detail.revision_id) + setMessage('Proposition supprimée. Son retrait sera propagé sans toucher aux préparations dérivées.') + setConfirming(false) + onBack() + return } + setConfirming(false) } catch (reason) { setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible') } finally { @@ -297,27 +345,28 @@ function Detail({ kind, identity, onBack, onEdit }: { kind: 'preparation' | 'pro {!withdrawn &&
- {kind === 'preparation' && } +
} {message &&

{message}

} {deleteError &&

{deleteError}

} - {confirming &&
-

Supprimer « {detail.title || 'Sans titre'} » ?

-

{detail.state === 'local' - ? 'La préparation sera retirée du planning. Elle n’a aucune livraison Android connue.' - : 'La préparation sera retirée du planning. Ses livraisons Android non commencées seront annulées lors de la prochaine synchronisation ; toute exécution commencée sera conservée.'}

-
- - -
-
} + {confirming && setConfirming(false)} + onConfirm={() => void remove()} + />}

{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}

{detail.title || 'Sans titre'}

{detail.planned_for === null ? 'Non planifiée' :

-
+ + + + {deleteError && !confirming &&

{deleteError}

} + {confirming && setConfirming(false)} + onConfirm={() => void remove()} + />}

{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}

{String(detail.session_type ?? 'Séance')}

{startedAt === null ? 'Début inconnu' :

setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} />
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) { @@ -495,6 +620,7 @@ export function SessionsPage() { onBack={back} />} {view !== 'programs' && <> + {statusMessage &&

{statusMessage}

}
@@ -508,13 +634,48 @@ export function SessionsPage() {

Date décroissante

{pending &&

Chargement…

}{failed &&

Les données ne sont pas disponibles.

} {!pending && !failed && visibleItems.length === 0 &&

Aucun résultat pour cette vue.

} -
{visibleItems.map((item) => )}
} +
{visibleItems.map((item) => { + const kind = deletionKind(item.collection) + return
+ + +
+ })}
+ {deleteTarget && setDeleteTarget(null)} + onConfirm={() => void confirmRowDeletion()} + />}} } diff --git a/web/src/theme/app.css b/web/src/theme/app.css index c8c0635..3dec232 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -85,8 +85,31 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .sort-indicator { width: max-content; margin: -.5rem 0 1rem; padding: .35rem .55rem; color: var(--lavender); background: rgb(180 190 254 / 8%); border-radius: .4rem; font: 650 var(--font-size-xs) var(--font-mono); } .session-search input, .form-grid input, .form-grid select, .form-grid textarea, .catalog-picker input, .occurrence-fields input, .occurrence-fields select { width: 100%; min-height: 2.7rem; padding: .65rem .75rem; border: 1px solid var(--surface-2); border-radius: .5rem; color: var(--text); background: var(--base); font-size: var(--font-size-md); } .session-list { display: grid; gap: .6rem; } -.session-row { width: 100%; padding: 1rem 1.1rem; display: grid; grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); gap: 1rem; align-items: center; text-align: left; color: var(--text); border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--mantle); cursor: pointer; } -.session-row:hover { border-color: var(--lavender); background: var(--base); } +.session-row { + align-items: stretch; + background: var(--mantle); + border: 1px solid var(--surface-1); + border-radius: .75rem; + display: grid; + grid-template-columns: minmax(0, 1fr) 3rem; + overflow: hidden; + width: 100%; +} +.session-row:hover { border-color: var(--lavender); } +.session-row-main { + align-items: center; + background: transparent; + border: 0; + color: var(--text); + cursor: pointer; + display: grid; + gap: 1rem; + grid-template-columns: minmax(12rem, 2fr) repeat(3, minmax(7rem, 1fr)); + padding: 1rem 1.1rem; + text-align: left; + width: 100%; +} +.session-row-main:hover { background: var(--base); } .session-row span:first-child { display: grid; gap: .2rem; } .session-row small { color: var(--lavender); font: 700 var(--font-size-xs) var(--font-mono); text-transform: uppercase; } .sessions-editor, .session-detail { padding: clamp(1rem, 3vw, 2.25rem); border: 1px solid var(--surface-1); border-radius: var(--radius); background: var(--mantle); } @@ -112,6 +135,37 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .delete-confirmation { margin-bottom: 1rem; padding: 1rem; border: 1px solid var(--red); border-radius: .75rem; background: var(--base); } .delete-confirmation h3 { margin-top: 0; } .delete-confirmation p { color: var(--subtext-0); line-height: 1.55; } +.delete-dialog-backdrop { + align-items: center; + background: rgb(17 17 27 / 78%); + display: flex; + inset: 0; + justify-content: center; + padding: 1rem; + position: fixed; + z-index: 30; +} +.delete-dialog-backdrop .delete-confirmation { + margin: 0; + max-height: calc(100vh - 2rem); + max-width: 34rem; + overflow-y: auto; + width: 100%; +} +.trash-action { + align-items: center; + align-self: stretch; + background: transparent; + border: 0; + color: var(--red); + display: inline-flex; + font-size: 1.1rem; + justify-content: center; + min-height: 44px; + min-width: 44px; +} +.trash-action:hover, +.trash-action:focus-visible { background: rgb(243 139 168 / 12%); } .program-list, .program-session-list { display: grid; gap: .75rem; } .program-session-list { padding: 0; list-style: none; } .program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; } @@ -124,8 +178,19 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); } .detail-tile code { overflow-wrap: anywhere; color: var(--lavender); } .draft-payload { max-height: 32rem; overflow: auto; color: var(--subtext-0); font: var(--font-size-sm)/1.6 var(--font-mono); white-space: pre-wrap; } -@media (max-width: 52rem) { .session-row { grid-template-columns: 1fr 1fr; } .form-grid { grid-template-columns: 1fr 1fr; } .occurrence-fields { grid-template-columns: 1fr 1fr; } } -@media (max-width: 30rem) { .session-row, .form-grid, .occurrence-fields { grid-template-columns: 1fr; } .session-tabs { width: 100%; overflow-x: auto; } .session-tabs button { white-space: nowrap; } .editor-actions { align-items: stretch; flex-direction: column; } } +@media (max-width: 52rem) { + .session-row-main { grid-template-columns: 1fr 1fr; } + .form-grid { grid-template-columns: 1fr 1fr; } + .occurrence-fields { grid-template-columns: 1fr 1fr; } +} +@media (max-width: 30rem) { + .session-row-main, + .form-grid, + .occurrence-fields { grid-template-columns: 1fr; } + .session-tabs { width: 100%; overflow-x: auto; } + .session-tabs button { white-space: nowrap; } + .editor-actions { align-items: stretch; flex-direction: column; } +} .dashboard-layout { min-width: 0; } .layout-toolbar { min-height: 2.7rem; margin: -1rem 0 1rem; display: flex; flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: .65rem; }