#include "trainlog/web_sessions.h" #include #include #include #include #include #include #include #include "database_internal.h" #include "timestamp.h" #include "trainlog/id.h" typedef struct ListDefinition { const char *kind; const char *sql; } ListDefinition; static const ListDefinition LISTS[] = { {"preparation", "SELECT p.preparation_id,r.title,r.planned_for,p.editing_state,COUNT(e.entry_id),p.updated_at," "p.editing_state,p.delivery_state " "FROM session_preparations p JOIN session_preparation_revisions r ON " "r.revision_id=p.current_revision_id " "LEFT JOIN session_preparation_entries e ON e.revision_id=r.revision_id " "WHERE p.withdrawn_at IS NULL AND NOT EXISTS(SELECT 1 FROM " "session_preparation_deliveries d JOIN sessions completed ON " "completed.session_id=d.execution_session_id WHERE d.preparation_id=p.preparation_id) " "AND (?1='' OR r.title LIKE '%'||?1||'%' COLLATE NOCASE OR " "EXISTS(SELECT 1 FROM " "session_preparation_entries pe JOIN exercises x ON x.exercise_id=pe.exercise_id WHERE " "pe.revision_id=r.revision_id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " "GROUP BY p.preparation_id ORDER BY " "COALESCE(r.planned_for,'9999-12-31'),p.updated_at,p.preparation_id LIMIT ?2 OFFSET ?3"}, {"proposal", "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,NULL,NULL " "FROM ai_session_drafts d LEFT JOIN ai_session_draft_entries e ON e.draft_row_id=d.id " "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 " "OFFSET ?3"}, {"draft", "SELECT " "d.session_id,d.session_type,substr(d.started_at,1,10),d.state,COALESCE(json_array_length(d." "payload_json,'$.exercises'),json_array_length(d.payload_json,'$.entries'),0)," "COALESCE(d.started_at,''),NULL,NULL " "FROM execution_drafts d WHERE NOT EXISTS(SELECT 1 FROM execution_draft_finalizations f WHERE " "f.session_id=d.session_id) " "AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='execution_draft' AND " "c.target_id=d.session_id AND c.deleted=1) " "AND (?1='' OR d.session_type LIKE '%'||?1||'%' COLLATE NOCASE) ORDER BY CASE d.state WHEN " "'active' THEN 0 ELSE 1 END,COALESCE(d.started_at,''),d.session_id LIMIT ?2 OFFSET ?3"}, {"history", "SELECT " "s.session_id,s.session_type,substr(s.started_at,1,10),'completed',COUNT(se.entry_id),s." "started_at,NULL,NULL " "FROM sessions s LEFT JOIN session_exercises se ON se.session_row_id=s.id " "WHERE (?1='' OR s.session_type LIKE '%'||?1||'%' COLLATE NOCASE OR EXISTS(SELECT 1 FROM " "session_exercises he JOIN exercises x ON x.id=he.exercise_row_id WHERE " "he.session_row_id=s.id AND x.name LIKE '%'||?1||'%' COLLATE NOCASE)) " "GROUP BY s.id ORDER BY s.started_at DESC,s.session_id LIMIT ?2 OFFSET ?3"}, }; static bool add_nullable_text(yyjson_mut_doc *document, yyjson_mut_val *object, const char *key, sqlite3_stmt *statement, int column) { if (sqlite3_column_type(statement, column) == SQLITE_NULL) { return yyjson_mut_obj_add_null(document, object, key); } if (sqlite3_column_type(statement, column) != SQLITE_TEXT) { return false; } return yyjson_mut_obj_add_strcpy( document, object, key, (const char *)sqlite3_column_text(statement, column)); } static TrainlogStatus write_document(yyjson_mut_doc *document, char **output_json, size_t *output_size) { yyjson_write_err error; char *json; json = yyjson_mut_write_opts(document, YYJSON_WRITE_NOFLAG, NULL, output_size, &error); yyjson_mut_doc_free(document); if (json == NULL || *output_size == 0U || *output_size > TRAINLOG_WEB_SESSIONS_JSON_MAX) { free(json); return TRAINLOG_STATUS_SYSTEM_ERROR; } *output_json = json; return TRAINLOG_STATUS_OK; } TrainlogStatus trainlog_web_sessions_sport_json(TrainlogDatabase *database, const char *start, const char *end, size_t offset, size_t limit, char **output_json, size_t *output_size) { static const char SQL[] = "SELECT s.session_id,s.session_type,s.started_at,s.ended_at," "COALESCE((SELECT group_concat(name, ', ') FROM (SELECT DISTINCT x.name AS name FROM " "session_exercises se JOIN exercises x ON x.id=se.exercise_row_id WHERE " "se.session_row_id=s.id ORDER BY se.position LIMIT 8)),s.session_type) " "FROM sessions s WHERE unixepoch(s.started_at) IS NOT NULL AND " "unixepoch(s.started_at)unixepoch(s.started_at) " "THEN unixepoch(s.ended_at)>unixepoch(?1) ELSE unixepoch(s.started_at)>=unixepoch(?1) " "END) AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " "c.target_kind='session' AND c.target_id=s.session_id AND c.deleted=1) " "ORDER BY unixepoch(s.started_at),s.session_id LIMIT ?3 OFFSET ?4"; sqlite3_stmt *statement = NULL; yyjson_mut_doc *document = NULL; yyjson_mut_val *root; yyjson_mut_val *items; TrainlogStatus status; TrainlogStatus end_status; size_t count = 0U; int step = SQLITE_DONE; TrainlogTimestampKey start_key; TrainlogTimestampKey end_key; if (database == NULL || start == NULL || end == NULL || output_json == NULL || output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || offset > INT64_MAX || strlen(start) > 40U || strlen(end) > 40U || !trainlog_timestamp_parse(start, strlen(start), &start_key) || !trainlog_timestamp_parse(end, strlen(end), &end_key) || trainlog_timestamp_compare(&start_key, &end_key) >= 0) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; status = trainlog_database_read_snapshot_begin(database); if (status != TRAINLOG_STATUS_OK) { return status; } if (sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, start, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, end, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int64(statement, 3, (sqlite3_int64)(limit + 1U)) != SQLITE_OK || sqlite3_bind_int64(statement, 4, (sqlite3_int64)offset) != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish_sport; } document = yyjson_mut_doc_new(NULL); root = document == NULL ? NULL : yyjson_mut_obj(document); items = document == NULL ? NULL : yyjson_mut_arr(document); if (root == NULL || items == NULL) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish_sport; } yyjson_mut_doc_set_root(document, root); if (!yyjson_mut_obj_add_uint(document, root, "api_version", 1U) || !yyjson_mut_obj_add_uint(document, root, "offset", offset) || !yyjson_mut_obj_add_val(document, root, "items", items)) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish_sport; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { yyjson_mut_val *item; if (count == limit) { ++count; break; } item = yyjson_mut_obj(document); if (item == NULL || !add_nullable_text(document, item, "identity", statement, 0) || !add_nullable_text(document, item, "session_type", statement, 1) || !add_nullable_text(document, item, "started_at", statement, 2) || !add_nullable_text(document, item, "ended_at", statement, 3) || !add_nullable_text(document, item, "label", statement, 4) || !yyjson_mut_arr_add_val(items, item)) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish_sport; } ++count; } if (step != SQLITE_DONE && count <= limit) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish_sport; } if (!yyjson_mut_obj_add_bool(document, root, "more", count > limit) || !yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish_sport; } status = TRAINLOG_STATUS_OK; finish_sport: if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && status == TRAINLOG_STATUS_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; } end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); if (status == TRAINLOG_STATUS_OK) { status = end_status; } if (status != TRAINLOG_STATUS_OK) { yyjson_mut_doc_free(document); return status; } return write_document(document, output_json, output_size); } TrainlogStatus trainlog_web_sessions_list_json(TrainlogDatabase *database, const TrainlogWebSessionsPageQuery *query, char **output_json, size_t *output_size) { yyjson_mut_doc *document = NULL; yyjson_mut_val *root; yyjson_mut_val *items; sqlite3_stmt *statement = NULL; TrainlogStatus status; TrainlogStatus end_status; size_t count = 0U; int step; const ListDefinition *definition; if (database == NULL || query == NULL || output_json == NULL || output_size == NULL || query->collection > TRAINLOG_WEB_SESSION_HISTORY || query->limit == 0U || query->limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || query->offset > INT64_MAX) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; definition = &LISTS[(size_t)query->collection]; status = trainlog_database_read_snapshot_begin(database); if (status != TRAINLOG_STATUS_OK) { return status; } if (sqlite3_prepare_v2(database->connection, definition->sql, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text( statement, 1, query->search == NULL ? "" : query->search, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int64(statement, 2, (sqlite3_int64)(query->limit + 1U)) != SQLITE_OK || sqlite3_bind_int64(statement, 3, (sqlite3_int64)query->offset) != SQLITE_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish; } document = yyjson_mut_doc_new(NULL); root = document == NULL ? NULL : yyjson_mut_obj(document); items = document == NULL ? NULL : yyjson_mut_arr(document); if (document == NULL || root == NULL || items == NULL) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish; } 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", definition->kind) || !yyjson_mut_obj_add_uint(document, root, "offset", query->offset) || !yyjson_mut_obj_add_val(document, root, "items", items)) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { yyjson_mut_val *item; sqlite3_int64 occurrence_count; if (count == query->limit) { ++count; break; } occurrence_count = sqlite3_column_int64(statement, 4); item = yyjson_mut_obj(document); if (item == NULL || occurrence_count < 0 || !add_nullable_text(document, item, "identity", statement, 0) || !add_nullable_text(document, item, "title", statement, 1) || !add_nullable_text(document, item, "date", statement, 2) || !add_nullable_text(document, item, "state", statement, 3) || !yyjson_mut_obj_add_sint(document, item, "occurrence_count", occurrence_count) || !add_nullable_text(document, item, "sort_timestamp", statement, 5) || !add_nullable_text(document, item, "editing_state", statement, 6) || !add_nullable_text(document, item, "delivery_state", statement, 7) || !yyjson_mut_arr_add_val(items, item)) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish; } ++count; } if (step != SQLITE_DONE && count <= query->limit) { status = TRAINLOG_STATUS_DATABASE_ERROR; goto finish; } if (!yyjson_mut_obj_add_bool(document, root, "more", count > query->limit) || !yyjson_mut_obj_add_uint(document, root, "next_offset", query->offset + query->limit)) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto finish; } status = TRAINLOG_STATUS_OK; finish: if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && status == TRAINLOG_STATUS_OK) { status = TRAINLOG_STATUS_DATABASE_ERROR; } end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); if (status == TRAINLOG_STATUS_OK) { status = end_status; } if (status != TRAINLOG_STATUS_OK) { yyjson_mut_doc_free(document); return status; } return write_document(document, output_json, output_size); } static TrainlogStatus detail_for_revision(TrainlogDatabase *database, const char *identity, bool proposal, yyjson_mut_doc *document, yyjson_mut_val *root) { const char *sql = proposal ? "SELECT " "d.draft_id,COALESCE(d.title,''),d.planned_for,d.session_type,d.notes," "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 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," "CASE WHEN p.withdrawn_at IS NULL THEN p.delivery_state ELSE 'withdrawn' END," "p.editing_state,p.delivery_state," "p.source_proposal_id,source.title,p.withdrawn_at,w.withdrawal_id," "w.acknowledged_at FROM session_preparations p JOIN " "session_preparation_revisions r ON r.revision_id=p.current_revision_id " "LEFT JOIN ai_session_drafts source ON source.draft_id=p.source_proposal_id " "LEFT JOIN session_preparation_withdrawals w ON " "w.preparation_id=p.preparation_id WHERE p.preparation_id=?1"; const char *entry_sql = proposal ? "SELECT " "e.entry_id,e.position,x.exercise_id,x.name,e.equipment_id,e.recording_mode,e." "tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_duration_seconds,e." "target_weight_kg,e.rest_seconds,NULL FROM ai_session_draft_entries e JOIN exercises " "x ON x.id=e.exercise_row_id JOIN ai_session_drafts d ON d.id=e.draft_row_id WHERE " "d.draft_id=?1 ORDER BY e.position,e.entry_id" : "SELECT " "e.entry_id,e.position,e.exercise_id,COALESCE(x.name,e.exercise_id),e.equipment_id,e." "recording_mode,e.tracking_mode,e.load_mode,e.target_sets,e.target_reps,e.target_" "duration_seconds,e.target_weight_kg,e.rest_seconds,e.notes FROM " "session_preparation_entries e LEFT JOIN exercises x ON x.exercise_id=e.exercise_id " "JOIN session_preparations p ON p.current_revision_id=e.revision_id WHERE " "p.preparation_id=?1 ORDER BY e.position,e.entry_id"; sqlite3_stmt *statement = NULL; yyjson_mut_val *entries; int step; int column; if (sqlite3_prepare_v2(database->connection, sql, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_NOT_FOUND; } for (column = 0; column < 8; ++column) { static const char *const keys[] = {"identity", "title", "planned_for", "session_type", "notes", "source_fingerprint", "revision_id", "state"}; if (!add_nullable_text(document, root, keys[column], statement, column)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } } if (!proposal) { if (!add_nullable_text(document, root, "editing_state", statement, 8) || !add_nullable_text(document, root, "delivery_state", statement, 9)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } static const char *const provenance_keys[] = {"source_proposal_id", "source_proposal_title", "withdrawn_at", "withdrawal_id", "withdrawal_acknowledged_at"}; for (column = 0; column < 5; ++column) { if (!add_nullable_text( document, root, provenance_keys[column], statement, column + 10)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } } } if (sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } statement = NULL; entries = yyjson_mut_arr(document); if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) || sqlite3_prepare_v2(database->connection, entry_sql, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_DATABASE_ERROR; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", "equipment_id", "recording_mode", "tracking_mode", "load_mode"}; yyjson_mut_val *entry = yyjson_mut_obj(document); int index; if (entry == NULL || !yyjson_mut_obj_add_sint( document, entry, "position", sqlite3_column_int64(statement, 1))) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_SYSTEM_ERROR; } for (index = 0; index < 8; ++index) { if (text_keys[index] != NULL && !add_nullable_text(document, entry, text_keys[index], statement, index)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } } for (index = 8; index <= 12; ++index) { static const char *const number_keys[] = {"target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds"}; if (sqlite3_column_type(statement, index) == SQLITE_NULL) { (void)yyjson_mut_obj_add_null(document, entry, number_keys[index - 8]); } else if (index == 11) { (void)yyjson_mut_obj_add_real(document, entry, number_keys[index - 8], sqlite3_column_double(statement, index)); } else { (void)yyjson_mut_obj_add_sint(document, entry, number_keys[index - 8], sqlite3_column_int64(statement, index)); } } if (!add_nullable_text(document, entry, "notes", statement, 13) || !yyjson_mut_arr_add_val(entries, entry)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } } if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } return TRAINLOG_STATUS_OK; } static TrainlogStatus detail_for_history(TrainlogDatabase *database, const char *identity, yyjson_mut_doc *document, yyjson_mut_val *root) { 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." "tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se." "target_weight_kg,se.rest_seconds,se.notes,mr.max_weight_kg FROM session_exercises se JOIN " "exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id LEFT " "JOIN " "max_results mr ON mr.session_exercise_row_id=se.id WHERE " "s.session_id=?1 ORDER BY se.position,se.entry_id"; static const char SET_SQL[] = "SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN " "session_exercises se ON se.id=ps.session_exercise_row_id WHERE se.entry_id=?1 ORDER BY " "ps.position"; sqlite3_stmt *statement = NULL; sqlite3_stmt *sets_statement = NULL; yyjson_mut_val *entries; int step; if (sqlite3_prepare_v2(database->connection, SESSION_SQL, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_NOT_FOUND; } if (!add_nullable_text(document, root, "identity", statement, 0) || !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, "revision_id", statement, 5)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } if (sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } statement = NULL; entries = yyjson_mut_arr(document); if (entries == NULL || !yyjson_mut_obj_add_val(document, root, "occurrences", entries) || sqlite3_prepare_v2(database->connection, ENTRY_SQL, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_DATABASE_ERROR; } while ((step = sqlite3_step(statement)) == SQLITE_ROW) { yyjson_mut_val *entry = yyjson_mut_obj(document); yyjson_mut_val *sets = yyjson_mut_arr(document); const char *entry_id = (const char *)sqlite3_column_text(statement, 0); int set_step; int column; static const char *const text_keys[] = {"entry_id", NULL, "exercise_id", "exercise_name", "equipment_id", "recording_mode", "tracking_mode", "load_mode"}; static const char *const numeric_keys[] = {"target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "rest_seconds", "notes", "max_weight_kg"}; if (entry == NULL || sets == NULL || !yyjson_mut_obj_add_sint( document, entry, "position", sqlite3_column_int64(statement, 1))) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_SYSTEM_ERROR; } for (column = 0; column < 8; ++column) { if (text_keys[column] != NULL && !add_nullable_text(document, entry, text_keys[column], statement, column)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } } for (column = 8; column <= 14; ++column) { if (column == 13) { (void)add_nullable_text( document, entry, numeric_keys[column - 8], statement, column); } else if (sqlite3_column_type(statement, column) == SQLITE_NULL) { (void)yyjson_mut_obj_add_null(document, entry, numeric_keys[column - 8]); } else if (column == 11 || column == 14) { (void)yyjson_mut_obj_add_real(document, entry, numeric_keys[column - 8], sqlite3_column_double(statement, column)); } else { (void)yyjson_mut_obj_add_sint(document, entry, numeric_keys[column - 8], sqlite3_column_int64(statement, column)); } } if (!yyjson_mut_obj_add_val(document, entry, "performed_sets", sets) || sqlite3_prepare_v2(database->connection, SET_SQL, -1, &sets_statement, NULL) != SQLITE_OK || sqlite3_bind_text(sets_statement, 1, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK) { (void)sqlite3_finalize(statement); if (sets_statement != NULL) { (void)sqlite3_finalize(sets_statement); } return TRAINLOG_STATUS_DATABASE_ERROR; } while ((set_step = sqlite3_step(sets_statement)) == SQLITE_ROW) { yyjson_mut_val *set = yyjson_mut_obj(document); if (set == NULL || !yyjson_mut_obj_add_sint( document, set, "position", sqlite3_column_int64(sets_statement, 0))) { (void)sqlite3_finalize(sets_statement); (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_SYSTEM_ERROR; } for (column = 1; column <= 3; ++column) { static const char *const keys[] = {"reps", "duration_seconds", "weight_kg"}; if (sqlite3_column_type(sets_statement, column) == SQLITE_NULL) { (void)yyjson_mut_obj_add_null(document, set, keys[column - 1]); } else if (column == 3) { (void)yyjson_mut_obj_add_real(document, set, keys[column - 1], sqlite3_column_double(sets_statement, column)); } else { (void)yyjson_mut_obj_add_sint(document, set, keys[column - 1], sqlite3_column_int64(sets_statement, column)); } } (void)yyjson_mut_arr_add_val(sets, set); } if (set_step != SQLITE_DONE || sqlite3_finalize(sets_statement) != SQLITE_OK || !yyjson_mut_arr_add_val(entries, entry)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } sets_statement = NULL; } if (step != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } return TRAINLOG_STATUS_OK; } static TrainlogStatus detail_for_draft(TrainlogDatabase *database, const char *identity, yyjson_mut_doc *document, yyjson_mut_val *root) { sqlite3_stmt *statement = NULL; yyjson_doc *payload_document = NULL; yyjson_val *payload; yyjson_mut_val *copied_payload; if (sqlite3_prepare_v2( database->connection, "SELECT session_id,session_type,started_at,state,revision_id,payload_json FROM " "execution_drafts WHERE session_id=?1", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, identity, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_NOT_FOUND; } if (!add_nullable_text(document, root, "identity", statement, 0) || !add_nullable_text(document, root, "session_type", statement, 1) || !add_nullable_text(document, root, "started_at", statement, 2) || !add_nullable_text(document, root, "state", statement, 3) || !add_nullable_text(document, root, "revision_id", statement, 4)) { (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_DATABASE_ERROR; } payload_document = yyjson_read((const char *)sqlite3_column_text(statement, 5), (size_t)sqlite3_column_bytes(statement, 5), YYJSON_READ_NOFLAG); payload = payload_document == NULL ? NULL : yyjson_doc_get_root(payload_document); copied_payload = payload == NULL ? NULL : yyjson_val_mut_copy(document, payload); if (copied_payload == NULL || !yyjson_mut_obj_add_val(document, root, "payload", copied_payload) || sqlite3_finalize(statement) != SQLITE_OK) { yyjson_doc_free(payload_document); return TRAINLOG_STATUS_DATABASE_ERROR; } yyjson_doc_free(payload_document); return TRAINLOG_STATUS_OK; } TrainlogStatus trainlog_web_sessions_detail_json(TrainlogDatabase *database, const char *kind, const char *identity, char **output_json, size_t *output_size) { yyjson_mut_doc *document; yyjson_mut_val *root; TrainlogStatus status; TrainlogStatus end_status; if (database == NULL || kind == NULL || identity == NULL || identity[0] == '\0' || output_json == NULL || output_size == NULL) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; if (strcmp(kind, "preparation") != 0 && strcmp(kind, "proposal") != 0 && strcmp(kind, "history") != 0 && strcmp(kind, "draft") != 0) { return TRAINLOG_STATUS_NOT_FOUND; } document = yyjson_mut_doc_new(NULL); 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); (void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U); (void)yyjson_mut_obj_add_strcpy(document, root, "kind", kind); status = trainlog_database_read_snapshot_begin(database); if (status == TRAINLOG_STATUS_OK) { if (strcmp(kind, "history") == 0) { status = detail_for_history(database, identity, document, root); } else if (strcmp(kind, "draft") == 0) { status = detail_for_draft(database, identity, document, root); } else { status = detail_for_revision( database, identity, strcmp(kind, "proposal") == 0, document, root); } } end_status = trainlog_database_read_snapshot_end(database, status == TRAINLOG_STATUS_OK); if (status == TRAINLOG_STATUS_OK) { status = end_status; } if (status != TRAINLOG_STATUS_OK) { yyjson_mut_doc_free(document); return status; } return write_document(document, output_json, output_size); } TrainlogStatus trainlog_web_sessions_catalog_json(TrainlogDatabase *database, const char *search, size_t offset, size_t limit, char **output_json, size_t *output_size) { static const char SQL[] = "SELECT exercise_id,name,recording_mode,tracking_mode,data_fields FROM exercises " "WHERE NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE c.target_kind='exercise' " "AND c.target_id=exercises.exercise_id AND c.deleted=1) " "AND (?1='' OR name LIKE '%'||?1||'%' COLLATE NOCASE) " "ORDER BY name COLLATE NOCASE,exercise_id LIMIT ?2 OFFSET ?3"; sqlite3_stmt *statement = NULL; yyjson_mut_doc *document; yyjson_mut_val *root; yyjson_mut_val *items; size_t count = 0U; int step; if (database == NULL || output_json == NULL || output_size == NULL || limit == 0U || limit > TRAINLOG_WEB_SESSIONS_PAGE_MAX || offset > INT64_MAX) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; document = yyjson_mut_doc_new(NULL); root = document == NULL ? NULL : yyjson_mut_obj(document); items = document == NULL ? NULL : yyjson_mut_arr(document); if (document == NULL || root == NULL || items == NULL || sqlite3_prepare_v2(database->connection, SQL, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, search == NULL ? "" : search, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int64(statement, 2, (sqlite3_int64)(limit + 1U)) != SQLITE_OK || sqlite3_bind_int64(statement, 3, (sqlite3_int64)offset) != SQLITE_OK) { yyjson_mut_doc_free(document); if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_DATABASE_ERROR; } yyjson_mut_doc_set_root(document, root); (void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U); (void)yyjson_mut_obj_add_val(document, root, "items", items); while ((step = sqlite3_step(statement)) == SQLITE_ROW) { yyjson_mut_val *item; if (count == limit) { ++count; break; } item = yyjson_mut_obj(document); if (item == NULL || !add_nullable_text(document, item, "exercise_id", statement, 0) || !add_nullable_text(document, item, "name", statement, 1) || !add_nullable_text(document, item, "recording_mode", statement, 2) || !add_nullable_text(document, item, "tracking_mode", statement, 3) || !yyjson_mut_obj_add_sint( document, item, "data_fields", sqlite3_column_int64(statement, 4)) || !yyjson_mut_arr_add_val(items, item)) { (void)sqlite3_finalize(statement); yyjson_mut_doc_free(document); return TRAINLOG_STATUS_DATABASE_ERROR; } ++count; } if ((step != SQLITE_DONE && count <= limit) || sqlite3_finalize(statement) != SQLITE_OK || !yyjson_mut_obj_add_bool(document, root, "more", count > limit) || !yyjson_mut_obj_add_uint(document, root, "next_offset", offset + limit)) { yyjson_mut_doc_free(document); return TRAINLOG_STATUS_DATABASE_ERROR; } return write_document(document, output_json, output_size); } 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 json_object_has_only(yyjson_val *object, const char *const *allowed, size_t allowed_count) { yyjson_obj_iter iterator = yyjson_obj_iter_with(object); yyjson_val *key; while ((key = yyjson_obj_iter_next(&iterator)) != NULL) { size_t index; bool found = false; const char *name = yyjson_get_str(key); for (index = 0U; index < allowed_count; ++index) { if (name != NULL && strcmp(name, allowed[index]) == 0) { found = true; break; } } if (!found) { return false; } } return true; } static bool bounded_json_string(yyjson_val *value, size_t capacity, const char **output) { const char *text; size_t length; if (!yyjson_is_str(value)) { return false; } text = yyjson_get_str(value); length = yyjson_get_len(value); if (text == NULL || length >= capacity || memchr(text, '\0', length) != NULL) { return false; } *output = text; return true; } static bool bind_nullable_text(sqlite3_stmt *statement, int index, yyjson_val *value) { if (value == NULL || yyjson_is_null(value)) { return sqlite3_bind_null(statement, index) == SQLITE_OK; } return yyjson_is_str(value) && sqlite3_bind_text(statement, index, yyjson_get_str(value), (int)yyjson_get_len(value), SQLITE_TRANSIENT) == SQLITE_OK; } static bool program_session_exists(TrainlogDatabase *database, const char *program_id, const char *program_session_id) { sqlite3_stmt *statement = NULL; int result; bool found = false; result = sqlite3_prepare_v2(database->connection, "SELECT 1 FROM program_sessions s JOIN programs p ON " "p.program_id=s.program_id WHERE p.program_id=?1 AND " "s.program_session_id=?2 AND p.state='active' AND " "p.deleted_at IS NULL", -1, &statement, NULL); if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 1, program_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 2, program_session_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { found = sqlite3_step(statement) == SQLITE_ROW; } if (statement != NULL) { (void)sqlite3_finalize(statement); } return found; } static TrainlogStatus insert_preparation_root(TrainlogDatabase *database, const char *preparation_id, const char *revision_id, const char *created_at, const char *editing_state, const char *source_proposal_id, const char *source_payload_sha256, const char *source_program_id, const char *source_program_session_id) { static const char INSERT_SQL[] = "INSERT INTO session_preparations(" "preparation_id,current_revision_id,created_at,updated_at,editing_state," "delivery_state,source_proposal_id,source_payload_sha256,withdrawn_at," "source_program_id,source_program_session_id) " "VALUES(?1,?2,?3,?3,?4,'local',?5,?6,NULL,?7,?8)"; sqlite3_stmt *statement = NULL; int result; result = sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL); if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 2, revision_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 3, created_at, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = sqlite3_bind_text(statement, 4, editing_state, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = source_proposal_id == NULL ? sqlite3_bind_null(statement, 5) : sqlite3_bind_text(statement, 5, source_proposal_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = source_payload_sha256 == NULL ? sqlite3_bind_null(statement, 6) : sqlite3_bind_text(statement, 6, source_payload_sha256, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = source_program_id == NULL ? sqlite3_bind_null(statement, 7) : sqlite3_bind_text(statement, 7, source_program_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = source_program_session_id == NULL ? sqlite3_bind_null(statement, 8) : sqlite3_bind_text(statement, 8, source_program_session_id, -1, SQLITE_TRANSIENT); } if (result == SQLITE_OK) { result = sqlite3_step(statement); } if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; } static TrainlogStatus insert_preparation_entry(TrainlogDatabase *database, const char *revision_id, yyjson_val *entry, size_t position) { static const char *const ALLOWED[] = {"entry_id", "exercise_id", "equipment_id", "load_mode", "rest_seconds", "target_sets", "target_reps", "target_duration_seconds", "target_weight_kg", "notes"}; static const char INSERT_SQL[] = "INSERT INTO " "session_preparation_entries(revision_id,entry_id,position,exercise_id,equipment_id," "recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,target_reps," "target_duration_seconds,target_weight_kg,notes) " "SELECT " "?1,?2,?3,e.exercise_id,?5,e.recording_mode,e.tracking_mode,e.data_fields,?6,?7,?8,?9,?10,?" "11,?12 FROM exercises e " "WHERE e.exercise_id=?4 AND NOT EXISTS(SELECT 1 FROM sync_causal_state c WHERE " "c.target_kind='exercise' AND c.target_id=e.exercise_id AND c.deleted=1)"; sqlite3_stmt *statement = NULL; const char *entry_id; const char *exercise_id; const char *load_mode; char generated_id[TRAINLOG_GENERATED_ID_CAPACITY]; yyjson_val *value; int64_t rest_seconds; int rc; if (!yyjson_is_obj(entry) || !json_object_has_only(entry, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) || !bounded_json_string( yyjson_obj_get(entry, "exercise_id"), TRAINLOG_ID_MAX + 1U, &exercise_id) || !bounded_json_string(yyjson_obj_get(entry, "load_mode"), 16U, &load_mode) || (strcmp(load_mode, "none") != 0 && strcmp(load_mode, "external") != 0 && strcmp(load_mode, "assistance") != 0)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } value = yyjson_obj_get(entry, "entry_id"); if (value == NULL) { if (trainlog_id_generate("spe", generated_id, sizeof(generated_id)) != TRAINLOG_STATUS_OK) { return TRAINLOG_STATUS_SYSTEM_ERROR; } entry_id = generated_id; } else if (!bounded_json_string(value, TRAINLOG_ID_MAX + 1U, &entry_id) || strncmp(entry_id, "spe_", 4U) != 0) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } value = yyjson_obj_get(entry, "rest_seconds"); if (!yyjson_is_int(value)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } rest_seconds = yyjson_get_sint(value); if (rest_seconds < 0 || rest_seconds > 86400 || position > INT64_MAX || sqlite3_prepare_v2(database->connection, INSERT_SQL, -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, revision_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, entry_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int64(statement, 3, (sqlite3_int64)position) != SQLITE_OK || sqlite3_bind_text(statement, 4, exercise_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || !bind_nullable_text(statement, 5, yyjson_obj_get(entry, "equipment_id")) || sqlite3_bind_text(statement, 6, load_mode, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_int64(statement, 7, rest_seconds) != SQLITE_OK) { if (statement != NULL) { (void)sqlite3_finalize(statement); } return TRAINLOG_STATUS_INVALID_ARGUMENT; } value = yyjson_obj_get(entry, "target_sets"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 8); } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 99 || sqlite3_bind_int64(statement, 8, yyjson_get_sint(value)) != SQLITE_OK) { goto invalid; } value = yyjson_obj_get(entry, "target_reps"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 9); } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 10000 || sqlite3_bind_int64(statement, 9, yyjson_get_sint(value)) != SQLITE_OK) { goto invalid; } value = yyjson_obj_get(entry, "target_duration_seconds"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 10); } else if (!yyjson_is_int(value) || yyjson_get_sint(value) < 1 || yyjson_get_sint(value) > 604800 || sqlite3_bind_int64(statement, 10, yyjson_get_sint(value)) != SQLITE_OK) { goto invalid; } value = yyjson_obj_get(entry, "target_weight_kg"); if (value == NULL || yyjson_is_null(value)) { (void)sqlite3_bind_null(statement, 11); } else { double target_weight; if (!yyjson_is_num(value)) { goto invalid; } target_weight = yyjson_get_num(value); if (!isfinite(target_weight) || target_weight <= 0.0 || target_weight > 2000.0 || sqlite3_bind_double(statement, 11, target_weight) != SQLITE_OK) { goto invalid; } } if (!bind_nullable_text(statement, 12, yyjson_obj_get(entry, "notes"))) { goto invalid; } rc = sqlite3_step(statement); (void)sqlite3_finalize(statement); return rc == SQLITE_DONE && sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_CONFLICT; invalid: (void)sqlite3_finalize(statement); return TRAINLOG_STATUS_INVALID_ARGUMENT; } TrainlogStatus trainlog_web_sessions_save_json(TrainlogDatabase *database, const char *preparation_id, const char *expected_revision, const char *request_id, const char *save_body, size_t save_body_size, char **output_json, size_t *output_size) { static const char *const ALLOWED[] = {"title", "session_type", "planned_for", "notes", "editing_state", "occurrences", "source_proposal_id", "source_payload_sha256", "source_program_id", "source_program_session_id"}; yyjson_doc *input = NULL; yyjson_val *root; yyjson_val *occurrences; sqlite3_stmt *statement = NULL; char new_preparation[TRAINLOG_GENERATED_ID_CAPACITY]; char revision[TRAINLOG_GENERATED_ID_CAPACITY]; char now[TRAINLOG_TIMESTAMP_MAX + 1U]; const char *actual_preparation = preparation_id; const char *title; const char *session_type; const char *editing_state; const char *source_proposal = NULL; const char *source_fingerprint = NULL; const char *source_program = NULL; const char *source_program_session = NULL; size_t index; TrainlogStatus status = TRAINLOG_STATUS_OK; yyjson_mut_doc *response; yyjson_mut_val *response_root; if (database == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' || save_body == NULL || save_body_size == 0U || output_json == NULL || output_size == NULL || !timestamp_now(now)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; input = yyjson_read(save_body, save_body_size, YYJSON_READ_NOFLAG); root = input == NULL ? NULL : yyjson_doc_get_root(input); if (!yyjson_is_obj(root) || !json_object_has_only(root, ALLOWED, sizeof(ALLOWED) / sizeof(ALLOWED[0])) || !bounded_json_string(yyjson_obj_get(root, "title"), TRAINLOG_NAME_MAX + 1U, &title) || !bounded_json_string(yyjson_obj_get(root, "session_type"), 16U, &session_type) || !bounded_json_string(yyjson_obj_get(root, "editing_state"), 16U, &editing_state) || (strcmp(session_type, "training") != 0 && strcmp(session_type, "max_test") != 0) || (strcmp(editing_state, "draft") != 0 && strcmp(editing_state, "ready") != 0)) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } occurrences = yyjson_obj_get(root, "occurrences"); if (!yyjson_is_arr(occurrences) || yyjson_arr_size(occurrences) > 64U || (strcmp(editing_state, "ready") == 0 && (title[0] == '\0' || yyjson_arr_size(occurrences) == 0U))) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (yyjson_obj_get(root, "source_proposal_id") != NULL && (!bounded_json_string( yyjson_obj_get(root, "source_proposal_id"), TRAINLOG_ID_MAX + 1U, &source_proposal) || !bounded_json_string( yyjson_obj_get(root, "source_payload_sha256"), 65U, &source_fingerprint) || strlen(source_fingerprint) != 64U)) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (preparation_id != NULL && preparation_id[0] != '\0' && source_proposal != NULL) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (yyjson_obj_get(root, "source_program_id") != NULL && (!bounded_json_string( yyjson_obj_get(root, "source_program_id"), TRAINLOG_ID_MAX + 1U, &source_program) || !bounded_json_string(yyjson_obj_get(root, "source_program_session_id"), TRAINLOG_ID_MAX + 1U, &source_program_session))) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if ((source_program == NULL) != (source_program_session == NULL) || (preparation_id != NULL && preparation_id[0] != '\0' && source_program != NULL) || (source_proposal != NULL && source_program != NULL)) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } if (actual_preparation == NULL || actual_preparation[0] == '\0') { if (expected_revision[0] != '\0' || trainlog_id_generate("sp", new_preparation, sizeof(new_preparation)) != TRAINLOG_STATUS_OK) { yyjson_doc_free(input); return TRAINLOG_STATUS_INVALID_ARGUMENT; } actual_preparation = new_preparation; } if (trainlog_id_generate("spr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK || sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { yyjson_doc_free(input); return TRAINLOG_STATUS_DATABASE_ERROR; } /* INVARIANT: a replayed request returns its committed identity and does * not evaluate the now-stale expected revision again. */ if (sqlite3_prepare_v2( database->connection, "SELECT response_json FROM session_preparation_requests WHERE request_id=?1", -1, &statement, NULL) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW) { const char *saved = (const char *)sqlite3_column_text(statement, 0); *output_size = saved == NULL ? 0U : strlen(saved); *output_json = saved == NULL ? NULL : strdup(saved); (void)sqlite3_finalize(statement); (void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL); yyjson_doc_free(input); return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK; } if (statement != NULL) { (void)sqlite3_finalize(statement); statement = NULL; } if ((preparation_id == NULL || preparation_id[0] == '\0') && source_program != NULL) { if (!program_session_exists(database, source_program, source_program_session)) { status = TRAINLOG_STATUS_CONFLICT; goto rollback; } } if ((preparation_id == NULL || preparation_id[0] == '\0') && source_proposal != NULL) { if (sqlite3_prepare_v2( database->connection, "SELECT 1 FROM ai_session_draft_imports WHERE draft_id=?1 AND payload_sha256=?2", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, source_proposal, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, source_fingerprint, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { status = TRAINLOG_STATUS_CONFLICT; goto rollback; } (void)sqlite3_finalize(statement); statement = NULL; } if (preparation_id != NULL && preparation_id[0] != '\0') { const char *current = NULL; if (sqlite3_prepare_v2( database->connection, "SELECT current_revision_id FROM session_preparations WHERE preparation_id=?1 " "AND withdrawn_at IS NULL", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { status = TRAINLOG_STATUS_NOT_FOUND; goto rollback; } current = (const char *)sqlite3_column_text(statement, 0); if (current == NULL || strcmp(current, expected_revision) != 0) { status = TRAINLOG_STATUS_CONFLICT; goto rollback; } (void)sqlite3_finalize(statement); statement = NULL; } else { status = insert_preparation_root(database, actual_preparation, revision, now, editing_state, source_proposal, source_fingerprint, source_program, source_program_session); if (status != TRAINLOG_STATUS_OK) { goto rollback; } } if (statement != NULL) { (void)sqlite3_finalize(statement); statement = NULL; } if (sqlite3_prepare_v2( database->connection, "INSERT INTO session_preparation_revisions VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || (expected_revision[0] == '\0' ? sqlite3_bind_null(statement, 3) : sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT)) != SQLITE_OK || sqlite3_bind_text(statement, 4, title, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, session_type, -1, SQLITE_TRANSIENT) != SQLITE_OK || !bind_nullable_text(statement, 6, yyjson_obj_get(root, "planned_for")) || !bind_nullable_text(statement, 7, yyjson_obj_get(root, "notes")) || sqlite3_bind_text(statement, 8, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE) { status = TRAINLOG_STATUS_INVALID_ARGUMENT; goto rollback; } (void)sqlite3_finalize(statement); statement = NULL; for (index = 0U; index < yyjson_arr_size(occurrences); ++index) { status = insert_preparation_entry(database, revision, yyjson_arr_get(occurrences, index), index); if (status != TRAINLOG_STATUS_OK) { goto rollback; } } if (preparation_id != NULL && preparation_id[0] != '\0') { if (sqlite3_prepare_v2(database->connection, "UPDATE session_preparations SET " "current_revision_id=?1,updated_at=?2,editing_state=?3,delivery_" "state=CASE WHEN delivery_state='local' THEN 'local' ELSE 'pending' " "END WHERE preparation_id=?4 AND current_revision_id=?5 AND " "withdrawn_at IS NULL", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, editing_state, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1) { status = TRAINLOG_STATUS_CONFLICT; goto rollback; } (void)sqlite3_finalize(statement); statement = NULL; } response = yyjson_mut_doc_new(NULL); response_root = response == NULL ? NULL : yyjson_mut_obj(response); if (response == NULL || response_root == NULL) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto rollback; } yyjson_mut_doc_set_root(response, response_root); (void)yyjson_mut_obj_add_uint(response, response_root, "api_version", 1U); (void)yyjson_mut_obj_add_strcpy(response, response_root, "preparation_id", actual_preparation); (void)yyjson_mut_obj_add_strcpy(response, response_root, "revision_id", revision); (void)yyjson_mut_obj_add_strcpy(response, response_root, "editing_state", editing_state); status = write_document(response, output_json, output_size); if (status != TRAINLOG_STATUS_OK || sqlite3_prepare_v2(database->connection, "INSERT INTO session_preparation_requests VALUES(?1,'save',?2,?3,?4,?5)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, actual_preparation, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { statement = NULL; status = TRAINLOG_STATUS_DATABASE_ERROR; goto rollback; } yyjson_doc_free(input); return TRAINLOG_STATUS_OK; rollback: if (statement != NULL) { (void)sqlite3_finalize(statement); } (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); free(*output_json); *output_json = NULL; *output_size = 0U; yyjson_doc_free(input); return status; } TrainlogStatus trainlog_web_sessions_deliver_json(TrainlogDatabase *database, const char *preparation_id, const char *expected_revision, const char *request_id, char **output_json, size_t *output_size) { sqlite3_stmt *statement = NULL; char delivery_id[TRAINLOG_GENERATED_ID_CAPACITY]; char execution_id[TRAINLOG_GENERATED_ID_CAPACITY]; char now[TRAINLOG_TIMESTAMP_MAX + 1U]; yyjson_mut_doc *response = NULL; yyjson_mut_val *root; TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR; if (database == NULL || preparation_id == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' || output_json == NULL || output_size == NULL || trainlog_id_generate("spd", delivery_id, sizeof(delivery_id)) != TRAINLOG_STATUS_OK || trainlog_id_generate("se", execution_id, sizeof(execution_id)) != TRAINLOG_STATUS_OK || !timestamp_now(now)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } if (sqlite3_prepare_v2(database->connection, "SELECT response_json FROM session_preparation_requests WHERE " "request_id=?1 AND command='deliver'", -1, &statement, NULL) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW) { const char *saved = (const char *)sqlite3_column_text(statement, 0); *output_size = saved == NULL ? 0U : strlen(saved); *output_json = saved == NULL ? NULL : strdup(saved); (void)sqlite3_finalize(statement); (void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL); return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK; } if (statement != NULL) { (void)sqlite3_finalize(statement); statement = NULL; } if (sqlite3_prepare_v2( database->connection, "SELECT 1 FROM session_preparations p WHERE p.preparation_id=?1 AND " "p.current_revision_id=?2 AND p.withdrawn_at IS NULL AND p.editing_state='ready' " "AND p.delivery_state='local' AND NOT EXISTS(SELECT 1 FROM " "session_preparation_deliveries d WHERE d.preparation_id=p.preparation_id AND " "d.state IN('pending','acknowledged','remote_unknown')) " "AND EXISTS(SELECT 1 FROM " "session_preparation_entries e WHERE e.revision_id=p.current_revision_id)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_ROW) { status = TRAINLOG_STATUS_CONFLICT; goto delivery_rollback; } (void)sqlite3_finalize(statement); statement = NULL; if (sqlite3_prepare_v2( database->connection, "INSERT INTO session_preparation_deliveries VALUES(?1,?2,?3,?4,'pending',?5,NULL,NULL)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, delivery_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, execution_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE) { goto delivery_rollback; } (void)sqlite3_finalize(statement); statement = NULL; response = yyjson_mut_doc_new(NULL); root = response == NULL ? NULL : yyjson_mut_obj(response); if (response == NULL || root == NULL) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto delivery_rollback; } 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, "delivery_id", delivery_id); (void)yyjson_mut_obj_add_strcpy(response, root, "execution_session_id", execution_id); (void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision); status = write_document(response, output_json, output_size); response = NULL; if (status != TRAINLOG_STATUS_OK || sqlite3_prepare_v2( database->connection, "INSERT INTO session_preparation_requests VALUES(?1,'deliver',?2,?3,?4,?5)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto delivery_rollback; } statement = NULL; if (sqlite3_prepare_v2(database->connection, "UPDATE session_preparations SET delivery_state='pending' WHERE " "preparation_id=?1 AND current_revision_id=?2", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 || sqlite3_finalize(statement) != SQLITE_OK || sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { statement = NULL; goto delivery_rollback; } return TRAINLOG_STATUS_OK; delivery_rollback: if (statement != NULL) { (void)sqlite3_finalize(statement); } yyjson_mut_doc_free(response); (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); free(*output_json); *output_json = NULL; *output_size = 0U; return status; } TrainlogStatus trainlog_web_sessions_withdraw_json(TrainlogDatabase *database, const char *preparation_id, const char *expected_revision, const char *request_id, char **output_json, size_t *output_size) { sqlite3_stmt *statement = NULL; char withdrawal_id[TRAINLOG_GENERATED_ID_CAPACITY]; char now[TRAINLOG_TIMESTAMP_MAX + 1U]; yyjson_mut_doc *response = NULL; yyjson_mut_val *root; TrainlogStatus status = TRAINLOG_STATUS_DATABASE_ERROR; bool has_deliveries = false; if (database == NULL || preparation_id == NULL || expected_revision == NULL || request_id == NULL || request_id[0] == '\0' || output_json == NULL || output_size == NULL || trainlog_id_generate("spw", withdrawal_id, sizeof(withdrawal_id)) != TRAINLOG_STATUS_OK || !timestamp_now(now)) { return TRAINLOG_STATUS_INVALID_ARGUMENT; } *output_json = NULL; *output_size = 0U; if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { return TRAINLOG_STATUS_DATABASE_ERROR; } /* INVARIANT: a lost HTTP response may be replayed without creating a * second withdrawal or reconsidering a now-inactive preparation. */ if (sqlite3_prepare_v2(database->connection, "SELECT command,preparation_id,revision_id,response_json FROM " "session_preparation_requests WHERE request_id=?1", -1, &statement, NULL) == SQLITE_OK && sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) == SQLITE_OK && sqlite3_step(statement) == SQLITE_ROW) { const char *command = (const char *)sqlite3_column_text(statement, 0); const char *saved_preparation = (const char *)sqlite3_column_text(statement, 1); const char *saved_revision = (const char *)sqlite3_column_text(statement, 2); const char *saved = (const char *)sqlite3_column_text(statement, 3); if (command == NULL || saved_preparation == NULL || saved_revision == NULL || strcmp(command, "withdraw") != 0 || strcmp(saved_preparation, preparation_id) != 0 || strcmp(saved_revision, expected_revision) != 0) { status = TRAINLOG_STATUS_CONFLICT; goto withdraw_rollback; } *output_size = saved == NULL ? 0U : strlen(saved); *output_json = saved == NULL ? NULL : strdup(saved); (void)sqlite3_finalize(statement); (void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL); return *output_json == NULL ? TRAINLOG_STATUS_SYSTEM_ERROR : TRAINLOG_STATUS_OK; } if (statement != NULL) { (void)sqlite3_finalize(statement); statement = NULL; } if (sqlite3_prepare_v2(database->connection, "SELECT EXISTS(SELECT 1 FROM session_preparation_deliveries d WHERE " "d.preparation_id=p.preparation_id) FROM session_preparations p WHERE " "p.preparation_id=?1 AND p.current_revision_id=?2 AND " "p.withdrawn_at IS NULL", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK) { goto withdraw_rollback; } if (sqlite3_step(statement) != SQLITE_ROW) { status = TRAINLOG_STATUS_CONFLICT; goto withdraw_rollback; } has_deliveries = sqlite3_column_int(statement, 0) != 0; if (sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto withdraw_rollback; } statement = NULL; /* CONTRACT: this transaction changes only planning availability and the * exportable withdrawal ledger. Delivery/execution evidence is retained * verbatim so a remotely started workout can never be erased here. */ if (sqlite3_prepare_v2(database->connection, "INSERT INTO session_preparation_withdrawals VALUES(" "?1,?2,?3,?4,NULL,NULL)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, withdrawal_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; goto withdraw_rollback; } statement = NULL; if (sqlite3_prepare_v2(database->connection, "UPDATE session_preparations SET withdrawn_at=?1,updated_at=?1 WHERE " "preparation_id=?2 AND current_revision_id=?3 AND withdrawn_at IS NULL", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_changes(database->connection) != 1 || sqlite3_finalize(statement) != SQLITE_OK) { statement = NULL; status = TRAINLOG_STATUS_CONFLICT; goto withdraw_rollback; } statement = NULL; response = yyjson_mut_doc_new(NULL); root = response == NULL ? NULL : yyjson_mut_obj(response); if (response == NULL || root == NULL) { status = TRAINLOG_STATUS_SYSTEM_ERROR; goto withdraw_rollback; } 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, "preparation_id", preparation_id); (void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", expected_revision); (void)yyjson_mut_obj_add_strcpy(response, root, "withdrawal_id", withdrawal_id); (void)yyjson_mut_obj_add_strcpy( response, root, "android_cancellation", has_deliveries ? "pending" : "not_required"); status = write_document(response, output_json, output_size); response = NULL; if (status != TRAINLOG_STATUS_OK || sqlite3_prepare_v2(database->connection, "INSERT INTO session_preparation_requests VALUES(" "?1,'withdraw',?2,?3,?4,?5)", -1, &statement, NULL) != SQLITE_OK || sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 2, preparation_id, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 4, *output_json, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT) != SQLITE_OK || sqlite3_step(statement) != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK || sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) { statement = NULL; status = TRAINLOG_STATUS_DATABASE_ERROR; goto withdraw_rollback; } return TRAINLOG_STATUS_OK; withdraw_rollback: if (statement != NULL) { (void)sqlite3_finalize(statement); } yyjson_mut_doc_free(response); (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); free(*output_json); *output_json = NULL; *output_size = 0U; return status; }