From fc851704924c33e0eb73175f7fc4c20402ca24cc Mon Sep 17 00:00:00 2001 From: andrewlalis Date: Sat, 30 Aug 2025 20:35:52 -0400 Subject: [PATCH] Add history stuff. --- finnow-api/source/account/api.d | 106 ++++------ finnow-api/source/account/data.d | 7 +- finnow-api/source/account/data_impl_sqlite.d | 188 +++++++++++++++--- finnow-api/source/account/dto.d | 101 ++++++++++ finnow-api/source/account/model.d | 7 + finnow-api/source/account/service.d | 2 +- finnow-api/source/api_mapping.d | 1 + finnow-api/source/history/data.d | 14 -- finnow-api/source/history/data_impl_sqlite.d | 90 --------- finnow-api/source/history/model.d | 46 ----- finnow-api/source/util/sqlite.d | 8 + finnow-api/sql/schema.sql | 7 +- web-app/src/api/account.ts | 32 +++ .../src/components/history/AccountHistory.vue | 76 +++++++ .../history/JournalEntryHistoryItem.vue | 23 +++ .../history/ValueRecordHistoryItem.vue | 32 +++ web-app/src/pages/AccountPage.vue | 3 + web-app/src/pages/LoginPage.vue | 14 +- 18 files changed, 494 insertions(+), 263 deletions(-) create mode 100644 finnow-api/source/account/dto.d delete mode 100644 finnow-api/source/history/data.d delete mode 100644 finnow-api/source/history/data_impl_sqlite.d delete mode 100644 finnow-api/source/history/model.d create mode 100644 web-app/src/components/history/AccountHistory.vue create mode 100644 web-app/src/components/history/JournalEntryHistoryItem.vue create mode 100644 web-app/src/components/history/ValueRecordHistoryItem.vue diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index 92e9ca5..62255bb 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -13,6 +13,7 @@ import profile.service; import profile.data; import account.model; import account.service; +import account.dto; import util.money; import util.pagination; import util.data; @@ -20,36 +21,6 @@ import account.data; import attachment.data; import attachment.dto; -/// The data the API provides for an Account entity. -struct AccountResponse { - import asdf : serdeTransformOut; - - ulong id; - string createdAt; - bool archived; - string type; - string numberSuffix; - string name; - Currency currency; - string description; - @serdeTransformOut!serializeOptional - Optional!long currentBalance; - - static AccountResponse of(in Account account, Optional!long currentBalance) { - AccountResponse r; - r.id = account.id; - r.createdAt = account.createdAt.toISOExtString(); - r.archived = account.archived; - r.type = account.type.id; - r.numberSuffix = account.numberSuffix; - r.name = account.name; - r.currency = account.currency; - r.description = account.description; - r.currentBalance = currentBalance; - return r; - } -} - void handleGetAccounts(ref ServerHttpRequest request, ref ServerHttpResponse response) { import std.algorithm; import std.array; @@ -67,15 +38,6 @@ void handleGetAccount(ref ServerHttpRequest request, ref ServerHttpResponse resp writeJsonBody(response, AccountResponse.of(account, getBalance(ds, account.id))); } -// The data provided by a user to create a new account. -struct AccountCreationPayload { - string type; - string numberSuffix; - string name; - string currency; - string description; -} - void handleCreateAccount(ref ServerHttpRequest request, ref ServerHttpResponse response) { auto ds = getProfileDataSource(request); AccountCreationPayload payload = readJsonBodyAs!AccountCreationPayload(request); @@ -117,34 +79,46 @@ void handleDeleteAccount(ref ServerHttpRequest request, ref ServerHttpResponse r ds.getAccountRepository().deleteById(accountId); } -const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); +void handleGetAccountHistory(ref ServerHttpRequest request, ref ServerHttpResponse response) { + ulong accountId = request.getPathParamOrThrow!ulong("accountId"); + PageRequest pagination = PageRequest.parse(request, PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)])); + auto ds = getProfileDataSource(request); + AccountRepository accountRepo = ds.getAccountRepository(); + auto page = accountRepo.getHistory(accountId, pagination); + writeHistoryResponse(response, page); +} -struct AccountValueRecordResponse { - ulong id; - string timestamp; - ulong accountId; - string type; - long value; - Currency currency; - AttachmentResponse[] attachments; - - static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) { - import std.algorithm : map; - import std.array : array; - return AccountValueRecordResponse( - vr.id, - vr.timestamp.toISOExtString(), - vr.accountId, - vr.type, - vr.value, - vr.currency, - attachmentRepo.findAllByValueRecordId(vr.id) - .map!(AttachmentResponse.of) - .array - ); +private string serializeAccountHistoryItem(in AccountHistoryItemResponse i) { + import asdf : serializeToJson; + if (i.type == AccountHistoryItemType.JournalEntry) { + return serializeToJson(cast(AccountHistoryJournalEntryItemResponse) i); + } else if (i.type == AccountHistoryItemType.ValueRecord) { + return serializeToJson(cast(AccountHistoryValueRecordItemResponse) i); + } else { + return serializeToJson(i); } } +private void writeHistoryResponse(ref ServerHttpResponse response, in Page!AccountHistoryItemResponse page) { + // Manual serialization of response due to inheritance structure. + import asdf; + import std.json; + string initialJsonObj = serializeToJson(page); + JSONValue obj = parseJSON(initialJsonObj); + obj.object["items"] = JSONValue.emptyArray; + foreach (item; page.items) { + string initialItemJson = serializeAccountHistoryItem(item); + obj.object["items"].array ~= parseJSON(initialItemJson); + } + + string jsonStr = obj.toJSON(); + response.writeBodyString(jsonStr, ContentTypes.APPLICATION_JSON); +} + +// Value records: + +const PageRequest VALUE_RECORD_DEFAULT_PAGE_REQUEST = PageRequest(1, 10, [Sort("timestamp", SortDir.DESC)]); + void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); auto ds = getProfileDataSource(request); @@ -165,12 +139,6 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo)); } -struct ValueRecordCreationPayload { - string timestamp; - string type; - long value; -} - void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) { ulong accountId = request.getPathParamAs!ulong("accountId"); ProfileDataSource ds = getProfileDataSource(request); diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 69b779d..926de3f 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -3,9 +3,9 @@ module account.data; import handy_http_primitives : Optional; import account.model; +import account.dto; import util.money; import util.pagination; -import history.model; import std.datetime : SysTime; @@ -19,7 +19,8 @@ interface AccountRepository { Account[] findAll(); AccountCreditCardProperties getCreditCardProperties(ulong id); void setCreditCardProperties(ulong id, in AccountCreditCardProperties props); - History getHistory(ulong id); + + Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination); } interface AccountJournalEntryRepository { @@ -34,7 +35,7 @@ interface AccountJournalEntryRepository { ); void deleteById(ulong id); void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId); - AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl); + AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId); } interface AccountValueRecordRepository { diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index 193ae73..c73741c 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -7,7 +7,7 @@ import handy_http_primitives : Optional; import account.data; import account.model; -import history.model; +import account.dto; import util.sqlite; import util.money; import util.pagination; @@ -87,9 +87,9 @@ SQL", // Delete associated history. util.sqlite.update( db, - "DELETE FROM history + "DELETE FROM history_item WHERE id IN ( - SELECT history_id FROM account_history + SELECT history_item_id FROM account_history_item WHERE account_id = ? )", id @@ -150,31 +150,94 @@ SQL", } } - History getHistory(ulong id) { - if (!exists(db, "SELECT id FROM account WHERE id = ?", id)) { - throw new Exception("Account doesn't exist."); - } - Optional!History history = findOne( + private static struct BaseHistoryItem { + ulong id; + string timestamp; + string type; + } + + Page!AccountHistoryItemResponse getHistory(ulong accountId, in PageRequest pagination) { + ulong count = util.sqlite.count( db, - q"SQL - SELECT * FROM history - LEFT JOIN account_history ah ON ah.history_id = history.id - WHERE ah.account_id = ? -SQL", - r => History(r.peek!ulong(0)), - id + "SELECT COUNT(history_item_id) FROM account_history_item WHERE account_id = ?", + accountId ); - if (!history.empty) { - return history.value; + const baseQuery = "SELECT hi.id, hi.timestamp, hi.type " ~ + "FROM account_history_item ahi " ~ + "LEFT JOIN history_item hi ON ahi.history_item_id = hi.id " ~ + "WHERE ahi.account_id = ?"; + string query = baseQuery ~ " " ~ pagination.toSql(); + // First fetch the basic information about each item. + BaseHistoryItem[] items = util.sqlite.findAll( + db, + query, + (row) { + return BaseHistoryItem( + row.peek!ulong(0), + row.peek!string(1), + row.peek!string(2) + ); + }, + accountId + ); + // Then we'll do another query based on the type of history item. + // This is fine for paginated responses. + AccountHistoryItemResponse[] results; + foreach (BaseHistoryItem item; items) { + if (item.type == AccountHistoryItemType.ValueRecord) { + results ~= fetchValueRecordHistoryItem(item); + } else if (item.type == AccountHistoryItemType.JournalEntry) { + results ~= fetchJournalEntryHistoryItem(item); + } else { + AccountHistoryItemResponse base = new AccountHistoryItemResponse(); + base.timestamp = item.timestamp; + base.type = item.type; + results ~= base; + } } - // No history exists yet, so add it. - ulong historyId = doTransaction(db, () { - util.sqlite.update(db, "INSERT INTO history DEFAULT VALUES"); - ulong historyId = db.lastInsertRowid(); - util.sqlite.update(db, "INSERT INTO account_history (account_id, history_id) VALUES (?, ?)", id, historyId); - return historyId; - }); - return History(historyId); + return Page!AccountHistoryItemResponse.of(results, pagination, count); + } + + private AccountHistoryValueRecordItemResponse fetchValueRecordHistoryItem(in BaseHistoryItem item) { + return util.sqlite.findOne( + db, + "SELECT vr.id, vr.type, vr.value, vr.currency FROM history_item_linked_value_record h " ~ + "LEFT JOIN account_value_record vr ON vr.id = h.value_record_id " ~ + "WHERE h.item_id = ?", + (row) { + auto obj = new AccountHistoryValueRecordItemResponse(); + obj.timestamp = item.timestamp; + obj.type = item.type; + obj.valueRecordId = row.peek!ulong(0); + obj.valueRecordType = row.peek!string(1); + obj.value = row.peek!long(2); + obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(3)); + return obj; + }, + item.id + ).orElseThrow(); + } + + private AccountHistoryJournalEntryItemResponse fetchJournalEntryHistoryItem(in BaseHistoryItem item) { + return util.sqlite.findOne( + db, + "SELECT je.type, je.amount, je.currency, tx.id, tx.description FROM history_item_linked_journal_entry h " ~ + "LEFT JOIN account_journal_entry je ON je.id = h.journal_entry_id " ~ + "LEFT JOIN \"transaction\" tx ON tx.id = je.transaction_id " ~ + "WHERE h.item_id = ?", + (row) { + auto obj = new AccountHistoryJournalEntryItemResponse(); + obj.timestamp = item.timestamp; + obj.type = item.type; + obj.journalEntryType = row.peek!string(0); + obj.amount = row.peek!ulong(1); + obj.currency = Currency.ofCode(row.peek!(string, PeekMode.slice)(2)); + obj.transactionId = row.peek!ulong(3); + obj.transactionDescription = row.peek!string(4); + return obj; + }, + item.id + ).orElseThrow(); } static Account parseAccount(Row row) { @@ -231,15 +294,38 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository { type, currency.code ); - ulong id = db.lastInsertRowid(); - return findById(id).orElseThrow(); + ulong journalEntryId = db.lastInsertRowid(); + // Insert a history item that links to the journal entry. + ulong historyItemId = insertNewAccountHistoryItem( + db, timestamp, accountId, AccountHistoryItemType.JournalEntry); + util.sqlite.update( + db, + "INSERT INTO history_item_linked_journal_entry (item_id, journal_entry_id) VALUES (?, ?)", + historyItemId, + journalEntryId + ); + return findById(journalEntryId).orElseThrow(); } void deleteById(ulong id) { + util.sqlite.update( + db, + "DELETE FROM history_item WHERE id IN " ~ + "(SELECT item_id FROM history_item_linked_journal_entry WHERE journal_entry_id = ?)", + id + ); util.sqlite.deleteById(db, "account_journal_entry", id); } void deleteByAccountIdAndTransactionId(ulong accountId, ulong transactionId) { + util.sqlite.update( + db, + "DELETE FROM history_item WHERE id IN " ~ + "(SELECT j.item_id FROM history_item_linked_journal_entry j " ~ + " LEFT JOIN account_journal_entry je ON je.id = j.journal_entry_id " ~ + "WHERE je.account_id = ? AND je.transaction_id = ?)", + accountId, transactionId + ); util.sqlite.update( db, "DELETE FROM account_journal_entry WHERE account_id = ? AND transaction_id = ?", @@ -247,15 +333,15 @@ class SqliteAccountJournalEntryRepository : AccountJournalEntryRepository { ); } - AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl) { + AccountJournalEntry[] findAllBetween(SysTime startIncl, SysTime endIncl, ulong accountId) { const query = "SELECT * FROM account_journal_entry " ~ - "WHERE timestamp >= ? AND timestamp <= ? " ~ + "WHERE timestamp >= ? AND timestamp <= ? AND account_id = ? " ~ "ORDER BY timestamp ASC"; return util.sqlite.findAll( db, query, &parseEntry, - startIncl.toISOExtString(), endIncl.toISOExtString() + startIncl.toISOExtString(), endIncl.toISOExtString(), accountId ); } @@ -331,8 +417,17 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository { value, currency.code ); - ulong id = db.lastInsertRowid(); - return findById(accountId, id).orElseThrow(); + ulong valueRecordId = db.lastInsertRowid(); + // Insert a history item that links to this value record. + ulong historyItemId = insertNewAccountHistoryItem( + db, timestamp, accountId, AccountHistoryItemType.ValueRecord); + util.sqlite.update( + db, + "INSERT INTO history_item_linked_value_record (item_id, value_record_id) VALUES (?, ?)", + historyItemId, + valueRecordId + ); + return findById(accountId, valueRecordId).orElseThrow(); } void linkAttachment(ulong valueRecordId, ulong attachmentId) { @@ -345,6 +440,13 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository { } void deleteById(ulong accountId, ulong id) { + // First delete any associated history items: + util.sqlite.update( + db, + "DELETE FROM history_item WHERE id IN " ~ + "(SELECT item_id FROM history_item_linked_value_record WHERE value_record_id = ?)", + id + ); util.sqlite.update( db, "DELETE FROM account_value_record WHERE account_id = ? AND id = ?", @@ -394,3 +496,25 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository { ); } } + +private ulong insertNewAccountHistoryItem( + ref Database db, + SysTime timestamp, + ulong accountId, + AccountHistoryItemType type +) { + util.sqlite.update( + db, + "INSERT INTO history_item (timestamp, type) VALUES (?, ?)", + timestamp.toISOExtString(), + type + ); + ulong historyItemId = db.lastInsertRowid(); + util.sqlite.update( + db, + "INSERT INTO account_history_item (account_id, history_item_id) VALUES (?, ?)", + accountId, + historyItemId + ); + return historyItemId; +} diff --git a/finnow-api/source/account/dto.d b/finnow-api/source/account/dto.d new file mode 100644 index 0000000..445e60b --- /dev/null +++ b/finnow-api/source/account/dto.d @@ -0,0 +1,101 @@ +module account.dto; + +import handy_http_primitives : Optional; +import account.model; +import attachment.data; +import attachment.dto; +import util.money; +import util.data : serializeOptional; + +/// The data the API provides for an Account entity. +struct AccountResponse { + import asdf : serdeTransformOut; + + ulong id; + string createdAt; + bool archived; + string type; + string numberSuffix; + string name; + Currency currency; + string description; + @serdeTransformOut!serializeOptional + Optional!long currentBalance; + + static AccountResponse of(in Account account, Optional!long currentBalance) { + AccountResponse r; + r.id = account.id; + r.createdAt = account.createdAt.toISOExtString(); + r.archived = account.archived; + r.type = account.type.id; + r.numberSuffix = account.numberSuffix; + r.name = account.name; + r.currency = account.currency; + r.description = account.description; + r.currentBalance = currentBalance; + return r; + } +} + +// The data provided by a user to create a new account. +struct AccountCreationPayload { + string type; + string numberSuffix; + string name; + string currency; + string description; +} + +struct AccountValueRecordResponse { + ulong id; + string timestamp; + ulong accountId; + string type; + long value; + Currency currency; + AttachmentResponse[] attachments; + + static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) { + import std.algorithm : map; + import std.array : array; + return AccountValueRecordResponse( + vr.id, + vr.timestamp.toISOExtString(), + vr.accountId, + vr.type, + vr.value, + vr.currency, + attachmentRepo.findAllByValueRecordId(vr.id) + .map!(AttachmentResponse.of) + .array + ); + } +} + +struct ValueRecordCreationPayload { + string timestamp; + string type; + long value; +} + +// Class-based inheritance structure for history item response format. + +class AccountHistoryItemResponse { + string timestamp; + string type; +} + +class AccountHistoryValueRecordItemResponse : AccountHistoryItemResponse { + ulong valueRecordId; + string valueRecordType; + long value; + Currency currency; +} + +class AccountHistoryJournalEntryItemResponse : AccountHistoryItemResponse { + string journalEntryType; + ulong amount; + Currency currency; + ulong transactionId; + string transactionDescription; +} diff --git a/finnow-api/source/account/model.d b/finnow-api/source/account/model.d index 8b5137c..9eb7f46 100644 --- a/finnow-api/source/account/model.d +++ b/finnow-api/source/account/model.d @@ -70,3 +70,10 @@ struct AccountValueRecord { long value; Currency currency; } + +enum AccountHistoryItemType : string { + Text = "TEXT", + PropertyChange = "PROPERTY_CHANGE", + ValueRecord = "VALUE_RECORD", + JournalEntry = "JOURNAL_ENTRY" +} diff --git a/finnow-api/source/account/service.d b/finnow-api/source/account/service.d index 09d740a..bc139b3 100644 --- a/finnow-api/source/account/service.d +++ b/finnow-api/source/account/service.d @@ -84,7 +84,7 @@ private long deriveBalance( } long balance = valueRecord.value; - AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp); + AccountJournalEntry[] journalEntries = journalEntryRepo.findAllBetween(startTimestamp, endTimestamp, account.id); foreach (entry; journalEntries) { long entryValue = entry.amount; if (entry.type == AccountJournalEntryType.CREDIT) { diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d index c9105ca..f27e013 100644 --- a/finnow-api/source/api_mapping.d +++ b/finnow-api/source/api_mapping.d @@ -55,6 +55,7 @@ HttpRequestHandler mapApiHandlers(string webOrigin) { a.map(HttpMethod.GET, ACCOUNT_PATH, &handleGetAccount); a.map(HttpMethod.PUT, ACCOUNT_PATH, &handleUpdateAccount); a.map(HttpMethod.DELETE, ACCOUNT_PATH, &handleDeleteAccount); + a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/history", &handleGetAccountHistory); a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records", &handleGetValueRecords); a.map(HttpMethod.GET, ACCOUNT_PATH ~ "/value-records/:valueRecordId:ulong", &handleGetValueRecord); a.map(HttpMethod.POST, ACCOUNT_PATH ~ "/value-records", &handleCreateValueRecord); diff --git a/finnow-api/source/history/data.d b/finnow-api/source/history/data.d deleted file mode 100644 index 844c9f4..0000000 --- a/finnow-api/source/history/data.d +++ /dev/null @@ -1,14 +0,0 @@ -module history.data; - -import std.datetime; -import handy_http_primitives : Optional; - -import history.model; - -interface HistoryRepository { - Optional!History findById(ulong id); - HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit); - HistoryItemText getTextItem(ulong itemId); - void addTextItem(ulong historyId, SysTime timestamp, string text); - void deleteById(ulong id); -} \ No newline at end of file diff --git a/finnow-api/source/history/data_impl_sqlite.d b/finnow-api/source/history/data_impl_sqlite.d deleted file mode 100644 index 6027ce0..0000000 --- a/finnow-api/source/history/data_impl_sqlite.d +++ /dev/null @@ -1,90 +0,0 @@ -module history.data_impl_sqlite; - -import std.datetime; -import handy_http_primitives : Optional; -import d2sqlite3; - -import history.data; -import history.model; -import util.sqlite; - -class SqliteHistoryRepository : HistoryRepository { - private Database db; - this(Database db) { - this.db = db; - } - - Optional!History findById(ulong id) { - Statement stmt = db.prepare("SELECT * FROM history WHERE id = ?"); - stmt.bind(1, id); - ResultRange result = stmt.execute(); - if (result.empty) return Optional!History.empty; - return Optional!History.of(parseHistory(result.front)); - } - - HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit) { - import std.conv; - import std.algorithm; - import std.array; - const query = q"SQL - SELECT * FROM history_item - WHERE history_id = ? AND timestamp <= ? - ORDER BY timestamp DESC - LIMIT -SQL"; - Statement stmt = db.prepare(query ~ " " ~ to!string(limit)); - stmt.bind(1, historyId); - stmt.bind(2, timestamp.toISOExtString()); - ResultRange result = stmt.execute(); - return result.map!(r => parseItem(r)).array; - } - - HistoryItemText getTextItem(ulong itemId) { - Statement stmt = db.prepare("SELECT * FROM history_item_text WHERE item_id = ?"); - stmt.bind(1, itemId); - ResultRange result = stmt.execute(); - if (result.empty) throw new Exception("No history item exists."); - return parseTextItem(result.front); - } - - void addTextItem(ulong historyId, SysTime timestamp, string text) { - ulong itemId = addItem(historyId, timestamp, HistoryItemType.TEXT); - update( - db, - "INSERT INTO history_item_text (item_id, content) VALUES (?, ?)", - itemId, text - ); - } - - void deleteById(ulong id) { - Statement stmt = db.prepare("DELETE FROM history WHERE id = ?"); - stmt.bind(1, id); - stmt.execute(); - } - - static History parseHistory(Row row) { - return History(row.peek!ulong(0)); - } - - static HistoryItem parseItem(Row row) { - return HistoryItem( - row.peek!ulong(0), - row.peek!ulong(1), - parseISOTimestamp(row, 2), - getHistoryItemType(row.peek!(string, PeekMode.slice)(3)) - ); - } - - static HistoryItemText parseTextItem(Row row) { - return HistoryItemText(row.peek!ulong(0), row.peek!string(1)); - } - - private ulong addItem(ulong historyId, SysTime timestamp, HistoryItemType type) { - update( - db, - "INSERT INTO history_item (history_id, timestamp, type) VALUES (?, ?, ?)", - historyId, timestamp, type - ); - return db.lastInsertRowid(); - } -} \ No newline at end of file diff --git a/finnow-api/source/history/model.d b/finnow-api/source/history/model.d deleted file mode 100644 index 84e914e..0000000 --- a/finnow-api/source/history/model.d +++ /dev/null @@ -1,46 +0,0 @@ -module history.model; - -import std.datetime.systime; - -/** - * A history containing a series of items, which all usually pertain to a - * certain target entity. - */ -struct History { - immutable ulong id; -} - -/** - * The type of history item. This can be used as a discriminator value to treat - * different history types separately. - */ -enum HistoryItemType : string { - TEXT = "TEXT" -} - -HistoryItemType getHistoryItemType(string text) { - import std.traits; - static foreach (t; EnumMembers!HistoryItemType) { - if (text == t) return t; - } - throw new Exception("Unknown history item type: " ~ text); -} - -/** - * A single item in a history. It has a UTC timestamp and a type. From the type, - * one can get more specific information. - */ -struct HistoryItem { - immutable ulong id; - immutable ulong historyId; - immutable SysTime timestamp; - immutable HistoryItemType type; -} - -/** - * Additional data for history items with the TEXT type. - */ -struct HistoryItemText { - immutable ulong itemId; - immutable string content; -} diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d index 196bcf9..0b45729 100644 --- a/finnow-api/source/util/sqlite.d +++ b/finnow-api/source/util/sqlite.d @@ -15,6 +15,14 @@ import handy_http_primitives : Optional; * args = Arguments for the query. * Returns: An optional result. */ +Optional!T findOne(T, Args...)(Database db, string query, T delegate(Row) resultMapper, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + ResultRange result = stmt.execute(); + if (result.empty) return Optional!T.empty; + return Optional!T.of(resultMapper(result.front)); +} +/// Overload that accepts a function. Optional!T findOne(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) { Statement stmt = db.prepare(query); stmt.bindAll(args); diff --git a/finnow-api/sql/schema.sql b/finnow-api/sql/schema.sql index 6cd6c3a..9e1c6d2 100644 --- a/finnow-api/sql/schema.sql +++ b/finnow-api/sql/schema.sql @@ -163,7 +163,8 @@ CREATE TABLE account_value_record_attachment ( CREATE TABLE history_item ( id INTEGER PRIMARY KEY, - timestamp TEXT NOT NULL + timestamp TEXT NOT NULL, + type TEXT NOT NULL ); CREATE TABLE account_history_item ( @@ -178,7 +179,7 @@ CREATE TABLE account_history_item ( ON UPDATE CASCADE ON DELETE CASCADE ); --- Zero or more plain text messages may be logged for any history item. +-- A plain text history item. CREATE TABLE history_item_text ( item_id INTEGER PRIMARY KEY, content TEXT NOT NULL, @@ -187,7 +188,7 @@ CREATE TABLE history_item_text ( ON UPDATE CASCADE ON DELETE CASCADE ); --- Zero or more property changes may be logged for any history item. +-- Zero or more property changes may be logged for a history item. CREATE TABLE history_item_property_change ( item_id INTEGER NOT NULL, property_name TEXT NOT NULL, diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index 3ff9e86..72d581a 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -94,6 +94,34 @@ export interface AccountValueRecordCreationPayload { value: number } +// History: +export enum AccountHistoryItemType { + TEXT = 'TEXT', + PROPERTY_CHANGE = 'PROPERTY_CHANGE', + VALUE_RECORD = 'VALUE_RECORD', + JOURNAL_ENTRY = 'JOURNAL_ENTRY', +} + +export interface AccountHistoryItem { + timestamp: string + type: AccountHistoryItemType +} + +export interface AccountHistoryValueRecordItem extends AccountHistoryItem { + valueRecordId: number + valueRecordType: AccountValueRecordType + value: number + currency: Currency +} + +export interface AccountHistoryJournalEntryItem extends AccountHistoryItem { + journalEntryType: AccountJournalEntryType + amount: number + currency: Currency + transactionId: number + transactionDescription: string +} + export class AccountApiClient extends ApiClient { readonly path: string @@ -122,6 +150,10 @@ export class AccountApiClient extends ApiClient { return super.delete(this.path + '/' + id) } + getHistory(id: number, pageRequest: PageRequest): Promise> { + return super.getJsonPage(`${this.path}/${id}/history`, pageRequest) + } + getValueRecords(accountId: number, pageRequest: PageRequest): Promise> { return super.getJsonPage(this.path + '/' + accountId + '/value-records', pageRequest) } diff --git a/web-app/src/components/history/AccountHistory.vue b/web-app/src/components/history/AccountHistory.vue new file mode 100644 index 0000000..7714133 --- /dev/null +++ b/web-app/src/components/history/AccountHistory.vue @@ -0,0 +1,76 @@ + + + diff --git a/web-app/src/components/history/JournalEntryHistoryItem.vue b/web-app/src/components/history/JournalEntryHistoryItem.vue new file mode 100644 index 0000000..1e1cef5 --- /dev/null +++ b/web-app/src/components/history/JournalEntryHistoryItem.vue @@ -0,0 +1,23 @@ + + diff --git a/web-app/src/components/history/ValueRecordHistoryItem.vue b/web-app/src/components/history/ValueRecordHistoryItem.vue new file mode 100644 index 0000000..9cb6cf6 --- /dev/null +++ b/web-app/src/components/history/ValueRecordHistoryItem.vue @@ -0,0 +1,32 @@ + + diff --git a/web-app/src/pages/AccountPage.vue b/web-app/src/pages/AccountPage.vue index 0cc191d..6795dcd 100644 --- a/web-app/src/pages/AccountPage.vue +++ b/web-app/src/pages/AccountPage.vue @@ -4,6 +4,7 @@ import { formatMoney } from '@/api/data'; import AddValueRecordModal from '@/components/AddValueRecordModal.vue'; import AppButton from '@/components/AppButton.vue'; import AppPage from '@/components/AppPage.vue'; +import AccountHistory from '@/components/history/AccountHistory.vue'; import PropertiesTable from '@/components/PropertiesTable.vue'; import { useProfileStore } from '@/stores/profile-store'; import { showConfirm } from '@/util/alert'; @@ -100,6 +101,8 @@ async function addValueRecord() { Delete + + diff --git a/web-app/src/pages/LoginPage.vue b/web-app/src/pages/LoginPage.vue index 3f5b3a1..efb8726 100644 --- a/web-app/src/pages/LoginPage.vue +++ b/web-app/src/pages/LoginPage.vue @@ -15,6 +15,7 @@ const router = useRouter() const route = useRoute() const authStore = useAuthStore() const apiClient = new AuthApiClient() +const isDev = import.meta.env.DEV const username = ref('') const password = ref('') @@ -48,11 +49,11 @@ function isDataValid() { return username.value.length > 0 && password.value.length >= 8 } -// function generateSampleData() { -// fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', { -// method: 'POST' -// }) -// } +function generateSampleData() { + fetch(import.meta.env.VITE_API_BASE_URL + '/sample-data', { + method: 'POST' + }) +}