trainlog/tui/src/web_sessions.c

1535 lines
73 KiB
C

#include "trainlog/web_sessions.h"
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
#include <sqlite3.h>
#include <yyjson.h>
#include "database_internal.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'),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_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;
}