feat(web): complete session deletion and programs

This commit is contained in:
fy59 2026-09-18 17:15:19 +02:00
parent c8a292326d
commit 4dffb05210
30 changed files with 2010 additions and 124 deletions

View file

@ -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()

View file

@ -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) {

View file

@ -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()

View file

@ -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

View file

@ -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")

View file

@ -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"]:

View file

@ -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",

View file

@ -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", ()),

View file

@ -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.

View file

@ -0,0 +1,27 @@
/* Trainlog Web Sessions deletion command service. */
#ifndef TRAINLOG_WEB_SESSION_DELETIONS_H
#define TRAINLOG_WEB_SESSION_DELETIONS_H
#include <stddef.h>
#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

View file

@ -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',

View file

@ -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);
}

View file

@ -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) {

View file

@ -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;

View file

@ -1,5 +1,6 @@
#include "trainlog/web_programs.h"
#include <math.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
@ -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;

View file

@ -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/",

View file

@ -0,0 +1,600 @@
#include "trainlog/web_session_deletions.h"
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <openssl/evp.h>
#include <sqlite3.h>
#include <yyjson.h>
#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;
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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;
}

View file

@ -15,6 +15,7 @@
#include <sqlite3.h>
#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);

View file

@ -0,0 +1,185 @@
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sqlite3.h>
#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;
}

View file

@ -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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
@ -152,7 +173,7 @@ export async function validateProgramImport(body: string): Promise<ProgramImport
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<ProgramImportPreview>
return programImportPreview(await response.json())
}
export async function importProgram(body: string): Promise<ProgramImportPreview> {
@ -160,7 +181,7 @@ export async function importProgram(body: string): Promise<ProgramImportPreview>
if (!response.ok) {
throw new Error(await errorReason(response))
}
return response.json() as Promise<ProgramImportPreview>
return programImportPreview(await response.json())
}
export async function archiveProgram(programId: string, revision: string): Promise<void> {

View file

@ -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<void> {
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)
}
}

View file

@ -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<HTMLElement | null>
onCancel: () => void
onConfirm: () => void
}
export function DeleteConfirmationDialog(props: DeleteConfirmationDialogProps) {
const dialog = useRef<HTMLDivElement>(null)
const cancel = useRef<HTMLButtonElement>(null)
useEffect(() => {
cancel.current?.focus()
const returnTarget = props.returnFocus.current
return () => returnTarget?.focus()
}, [props.returnFocus])
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape' && !props.busy) {
event.preventDefault()
props.onCancel()
return
}
if (event.key !== 'Tab') return
const buttons = dialog.current?.querySelectorAll<HTMLButtonElement>('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 <div className="delete-dialog-backdrop">
<div
ref={dialog}
className="delete-confirmation"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-dialog-title"
aria-describedby="delete-dialog-consequence"
onKeyDown={handleKeyDown}
>
<p className="eyebrow">{props.itemType}</p>
<h2 id="delete-dialog-title">{props.confirmLabel} « {props.title} » ?</h2>
{props.date && <p>{props.date}</p>}
<p id="delete-dialog-consequence">{props.consequence}</p>
{props.error && <p className="form-error" role="alert">{props.error}</p>}
<div className="confirmation-actions">
<button
ref={cancel}
type="button"
className="quiet-action"
disabled={props.busy}
onClick={props.onCancel}
>{props.cancelLabel}</button>
<button
type="button"
className="danger-action"
disabled={props.busy}
onClick={props.onConfirm}
>{props.busy ? 'Suppression…' : props.confirmLabel}</button>
</div>
</div>
</div>
}

View file

@ -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(<ProgramsTab detailProgramId={null} onOpen={onOpen} onBack={vi.fn()} />)
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(<ProgramsTab detailProgramId="pg_test" onOpen={vi.fn()} onBack={vi.fn()} />)
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)
})
})

View file

@ -101,6 +101,18 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
{' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''}
</p>
<ol className="program-occurrence-list">
{session.occurrences.map((occurrence) => <li key={occurrence.entry_id}>
<span>{occurrence.exercise_id}</span>
<small>
{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`}
</small>
</li>)}
</ol>
</div>
<button
type="button"
@ -219,11 +231,24 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
</div>
{preview && <section className="import-preview" aria-labelledby="import-preview-title">
<h2 id="import-preview-title">Aperçu avant import</h2>
<p><strong>{fileName}</strong></p>
<p><strong>{preview.title}</strong> · fichier {fileName}</p>
<p>
Identité : {preview.program_id} · {preview.session_count} séance
{preview.session_count} séance
{preview.session_count > 1 ? 's' : ''}
</p>
<p>
{preview.start_date
? formatCivilDate(preview.start_date, dateFormat)
: 'Début non défini'}
{' — '}
{preview.end_date
? formatCivilDate(preview.end_date, dateFormat)
: 'fin non définie'}
</p>
<p>{preview.unknown_exercise_count} identité d’exercice inconnue.</p>
{preview.warnings.length > 0 && <ul>
{preview.warnings.map((warning) => <li key={warning}>{warning}</li>)}
</ul>}
<p>Aucune donnée n’a encore été enregistrée.</p>
<div className="confirmation-actions">
<button type="button" onClick={cancelImport}>Annuler</button>

View file

@ -45,7 +45,14 @@ describe('Sessions page', () => {
}))
render(<SessionsPage />)
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' }))

View file

@ -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<HTMLButtonElement>(null)
const cancelDelete = useRef<HTMLButtonElement>(null)
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<SessionDetail | null>(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 <div className="error-panel" role="alert">Fiche indisponible. <button onClick={onBack}>Retour</button></div>
if (!detail) return <p aria-live="polite">Chargement de la fiche…</p>
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 && <div className="detail-actions">
<button type="button" className="primary-action" onClick={() => onEdit(detail)}>
{kind === 'preparation' ? 'Modifier' : 'Préparer à partir de cette proposition'}</button>
{kind === 'preparation' && <button ref={deleteTrigger} type="button" className="danger-action"
onClick={() => setConfirming(true)}>Supprimer la préparation</button>}
<button
ref={(element) => { deleteTrigger.current = element }}
type="button"
className="danger-action"
onClick={() => setConfirming(true)}
>{deletionLabel(kind)}</button>
</div>}</div>
{message && <p className="success-panel" role="status">{message}</p>}
{deleteError && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <section className="delete-confirmation" role="alertdialog" aria-modal="true"
aria-labelledby="delete-preparation-title">
<h3 id="delete-preparation-title">Supprimer « {detail.title || 'Sans titre'} » ?</h3>
<p>{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.'}</p>
<div className="confirmation-actions">
<button ref={cancelDelete} type="button" className="quiet-action" disabled={deleting}
onClick={() => {
setConfirming(false)
requestAnimationFrame(() => deleteTrigger.current?.focus())
}}>Conserver la préparation</button>
<button type="button" className="danger-action" disabled={deleting}
onClick={() => void remove()}>{deleting ? 'Suppression…' : 'Supprimer la préparation'}</button>
</div>
</section>}
{confirming && <DeleteConfirmationDialog
title={detail.title || 'Sans titre'}
itemType={deletionType(kind)}
date={detail.planned_for ? formatCivilDate(detail.planned_for, dateFormat) : null}
consequence={deletionConsequence(kind)}
cancelLabel="Conserver"
confirmLabel={deletionLabel(kind)}
busy={deleting}
error={deleteError}
returnFocus={deleteTrigger}
onCancel={() => setConfirming(false)}
onConfirm={() => void remove()}
/>}
<header className="detail-summary"><p className="eyebrow">{kind === 'proposal' ? 'PROPOSITION IA' : 'PRÉPARATION MANUELLE'}</p>
<h2>{detail.title || 'Sans titre'}</h2><p>
{detail.planned_for === null ? 'Non planifiée' : <time dateTime={detail.planned_for}>
@ -351,8 +400,12 @@ function ReadonlyDetail({ kind, identity, onBack }: {
kind: 'draft' | 'history'; identity: string; onBack: () => void
}) {
const { dateFormat } = useDatePreferences()
const deleteTrigger = useRef<HTMLElement | null>(null)
const [detail, setDetail] = useState<Record<string, unknown> | null>(null)
const [failed, setFailed] = useState(false)
const [confirming, setConfirming] = useState(false)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
useEffect(() => { const controller = new AbortController(); setFailed(false); setDetail(null)
fetchReadonlySessionDetail(kind, identity, controller.signal).then((value) => {
if (!controller.signal.aborted) setDetail(value)
@ -367,8 +420,47 @@ function ReadonlyDetail({ kind, identity, onBack }: {
? detail.payload as Record<string, unknown> : null
const startedAt = typeof detail.started_at === 'string' ? detail.started_at : null
const endedAt = typeof detail.ended_at === 'string' ? detail.ended_at : null
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
const title = kind === 'draft'
? String(detail.session_type ?? 'Brouillon')
: String(detail.session_type ?? 'Séance')
const remove = async () => {
setDeleting(true)
setDeleteError('')
try {
await deleteSessionResource(kind, identity, revision)
setConfirming(false)
onBack()
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
return <article className="session-detail"><div className="sessions-toolbar">
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button></div>
<button type="button" className="quiet-action" onClick={onBack}>← Retour à la liste</button>
<button
ref={(element) => { deleteTrigger.current = element }}
type="button"
className="danger-action"
disabled={!revision}
onClick={() => setConfirming(true)}
>{deletionLabel(kind)}</button>
</div>
{deleteError && !confirming && <p className="form-error" role="alert">{deleteError}</p>}
{confirming && <DeleteConfirmationDialog
title={title}
itemType={deletionType(kind)}
date={startedAt ? formatDateTime(startedAt, dateFormat) : null}
consequence={deletionConsequence(kind)}
cancelLabel="Conserver"
confirmLabel={deletionLabel(kind)}
busy={deleting}
error={deleteError}
returnFocus={deleteTrigger}
onCancel={() => setConfirming(false)}
onConfirm={() => void remove()}
/>}
<header className="detail-summary"><p className="eyebrow">{kind === 'draft' ? 'BROUILLON D’EXÉCUTION' : 'SÉANCE RÉALISÉE'}</p>
<h2>{String(detail.session_type ?? 'Séance')}</h2>
<p>{startedAt === null ? 'Début inconnu' : <time dateTime={startedAt}>
@ -402,6 +494,11 @@ export function SessionsPage() {
const [pending, setPending] = useState(true)
const [failed, setFailed] = useState(false)
const [editor, setEditor] = useState<SessionDetail | 'new' | null>(null)
const [deleteTarget, setDeleteTarget] = useState<SessionListEntry | null>(null)
const [deleting, setDeleting] = useState(false)
const [deleteError, setDeleteError] = useState('')
const [statusMessage, setStatusMessage] = useState('')
const deleteReturnFocus = useRef<HTMLElement | null>(null)
const [detailPath, setDetailPath] = useState(() => window.location.pathname)
const loadRevision = useRef(0)
const detailMatch = useMemo(() => detailPath.match(/^\/seances\/(preparation|proposal|draft|history)\/([^/]+)$/), [detailPath])
@ -458,6 +555,34 @@ export function SessionsPage() {
return owner === undefined ? `État non reconnu (${state})`
: sessionStateLabel(owner.collection, state)
}
const confirmRowDeletion = async () => {
if (!deleteTarget) return
const kind = deletionKind(deleteTarget.collection)
setDeleting(true)
setDeleteError('')
try {
if (kind === 'preparation' || kind === 'proposal') {
const detail = await fetchSessionDetail(kind, deleteTarget.identity)
if (kind === 'preparation') {
await withdrawPreparation(detail.identity, detail.revision_id)
} else {
await deleteSessionResource(kind, detail.identity, detail.revision_id)
}
} else {
const detail = await fetchReadonlySessionDetail(kind, deleteTarget.identity)
const revision = typeof detail.revision_id === 'string' ? detail.revision_id : ''
if (!revision) throw new Error('Cette ressource ne possède pas de révision supprimable.')
await deleteSessionResource(kind, deleteTarget.identity, revision)
}
setDeleteTarget(null)
setStatusMessage(deletionSuccess(kind))
load()
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleting(false)
}
}
if (editor) return <section className="page"><Editor initial={editor === 'new' ? undefined : editor}
onCancel={() => setEditor(null)} onSaved={(identity) => { setEditor(null); open('preparations', identity) }} /></section>
if (detailMatch && (detailMatch[1] === 'preparation' || detailMatch[1] === 'proposal')) {
@ -495,6 +620,7 @@ export function SessionsPage() {
onBack={back}
/>}
{view !== 'programs' && <>
{statusMessage && <p className="success-panel" role="status">{statusMessage}</p>}
<label className="session-search"><span>Rechercher par titre ou exercice</span><input type="search" value={search}
onChange={(event) => setSearch(event.target.value)} /></label>
<div className="session-filters" aria-label="Filtres de séances">
@ -508,13 +634,48 @@ export function SessionsPage() {
<p className="sort-indicator">Date décroissante</p>
{pending && <p aria-live="polite">Chargement…</p>}{failed && <p className="error-panel" role="alert">Les données ne sont pas disponibles.</p>}
{!pending && !failed && visibleItems.length === 0 && <p className="empty-inline">Aucun résultat pour cette vue.</p>}
<div className="session-list">{visibleItems.map((item) => <button type="button" className="session-row"
key={`${item.collection}-${item.identity}`} onClick={() => open(item.collection, item.identity)}>
<span><small>{item.collection === 'proposals' ? 'Proposition IA' : item.collection === 'preparations' ? 'Préparation manuelle' : item.collection === 'drafts' ? 'Brouillon d’exécution' : 'Séance réalisée'}</small>
<strong>{item.title || 'Sans titre'}</strong></span><span>{item.date === null
? item.collection === 'preparations' || item.collection === 'proposals' ? 'Non planifiée' : 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{sessionStateLabel(item.collection, item.state)}</span></button>)}</div></>}
<div className="session-list">{visibleItems.map((item) => {
const kind = deletionKind(item.collection)
return <div className="session-row" key={`${item.collection}-${item.identity}`}>
<button
type="button"
className="session-row-main"
onClick={() => open(item.collection, item.identity)}
>
<span><small>{deletionType(kind)}</small><strong>{item.title || 'Sans titre'}</strong></span>
<span>{item.date === null
? item.collection === 'preparations' || item.collection === 'proposals'
? 'Non planifiée'
: 'Date inconnue'
: <time dateTime={item.date}>{formatCivilDate(item.date, dateFormat)}</time>}</span>
<span>{item.occurrence_count} exercice{item.occurrence_count > 1 ? 's' : ''}</span>
<span>{sessionStateLabel(item.collection, item.state)}</span>
</button>
<button
type="button"
className="trash-action"
aria-label={deletionLabel(kind)}
title={deletionLabel(kind)}
onClick={(event) => {
deleteReturnFocus.current = event.currentTarget
setDeleteError('')
setDeleteTarget(item)
}}
><span aria-hidden="true">🗑</span></button>
</div>
})}</div>
{deleteTarget && <DeleteConfirmationDialog
title={deleteTarget.title || 'Sans titre'}
itemType={deletionType(deletionKind(deleteTarget.collection))}
date={deleteTarget.date ? formatCivilDate(deleteTarget.date, dateFormat) : null}
consequence={deletionConsequence(deletionKind(deleteTarget.collection))}
cancelLabel="Conserver"
confirmLabel={deletionLabel(deletionKind(deleteTarget.collection))}
busy={deleting}
error={deleteError}
returnFocus={deleteReturnFocus}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmRowDeletion()}
/>}</>}
</section>
}

View file

@ -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; }