diff --git a/finnow-api/schema.sql b/finnow-api/schema.sql index 636249d..4c0415c 100644 --- a/finnow-api/schema.sql +++ b/finnow-api/schema.sql @@ -31,7 +31,7 @@ CREATE TABLE account ( CREATE TABLE account_credit_card_properties ( account_id INTEGER PRIMARY KEY, - credit_limit TEXT, + credit_limit INTEGER, CONSTRAINT fk_account_credit_card_properties_account FOREIGN KEY (account_id) REFERENCES account(id) ON UPDATE CASCADE ON DELETE CASCADE @@ -64,7 +64,7 @@ CREATE TABLE "transaction" ( id INTEGER PRIMARY KEY, timestamp TEXT NOT NULL, added_at TEXT NOT NULL, - amount TEXT NOT NULL, + amount INTEGER NOT NULL, currency TEXT NOT NULL, description TEXT, vendor_id INTEGER, @@ -77,4 +77,132 @@ CREATE TABLE "transaction" ( ON UPDATE CASCADE ON DELETE SET NULL ); +CREATE TABLE transaction_attachment ( + transaction_id INTEGER NOT NULL, + attachment_id INTEGER NOT NULL, + PRIMARY KEY (transaction_id, attachment_id), + CONSTRAINT fk_transaction_attachment_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_attachment_attachment + FOREIGN KEY (attachment_id) REFERENCES attachment(id) + ON UPDATE CASCADE ON DELETE CASCADE +); +CREATE TABLE transaction_tag_join ( + transaction_id INTEGER NOT NULL, + tag_id INTEGER NOT NULL, + PRIMARY KEY (transaction_id, tag_id), + CONSTRAINT fk_transaction_tag_join_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_tag_join_tag + FOREIGN KEY (tag_id) REFERENCES transaction_tag(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_line_item ( + id INTEGER PRIMARY KEY, + transaction_id INTEGER NOT NULL, + value_per_item INTEGER NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + idx INTEGER NOT NULL DEFAULT 0, + description TEXT NOT NULL, + category_id INTEGER, + CONSTRAINT fk_transaction_line_item_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_transaction_line_item_category + FOREIGN KEY (category_id) REFERENCES transaction_category(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE account_journal_entry ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + account_id INTEGER NOT NULL, + transaction_id INTEGER NOT NULL, + amount INTEGER NOT NULL, + type TEXT NOT NULL, + currency TEXT NOT NULL, + CONSTRAINT fk_account_journal_entry_account + FOREIGN KEY (account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_account_journal_entry_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +-- Value records + +CREATE TABLE account_value_record ( + id INTEGER PRIMARY KEY, + timestamp TEXT NOT NULL, + account_id INTEGER NOT NULL, + type TEXT NOT NULL DEFAULT 'BALANCE', + balance INTEGER NOT NULL, + currency TEXT NOT NULL, + CONSTRAINT fk_balance_record_account + FOREIGN KEY (account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE account_value_record_attachment ( + value_record_id BIGINT NOT NULL, + attachment_id BIGINT NOT NULL, + PRIMARY KEY (value_record_id, attachment_id), + CONSTRAINT fk_account_value_record_attachment_value_record + FOREIGN KEY (value_record_id) REFERENCES account_value_record(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_account_value_record_attachment_attachment + FOREIGN KEY (attachment_id) REFERENCES attachment(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +-- History Entities + +CREATE TABLE history ( + id INTEGER PRIMARY KEY +); + +CREATE TABLE history_item ( + id INTEGER PRIMARY KEY, + history_id INTEGER NOT NULL, + timestamp TEXT NOT NULL, + type TEXT NOT NULL, + CONSTRAINT fk_history_item_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE history_item_text ( + item_id INTEGER PRIMARY KEY, + content TEXT NOT NULL, + CONSTRAINT fk_history_item_text_item + FOREIGN KEY (item_id) REFERENCES history_item(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE account_history ( + account_id INTEGER NOT NULL UNIQUE, + history_id INTEGER NOT NULL, + PRIMARY KEY (account_id, history_id), + CONSTRAINT fk_account_history_account + FOREIGN KEY (account_id) REFERENCES account(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_account_history_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); + +CREATE TABLE transaction_history ( + transaction_id INTEGER NOT NULL UNIQUE, + history_id INTEGER NOT NULL, + PRIMARY KEY (transaction_id, history_id), + CONSTRAINT fk_history_transaction_transaction + FOREIGN KEY (transaction_id) REFERENCES "transaction"(id) + ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT fk_history_transaction_history + FOREIGN KEY (history_id) REFERENCES history(id) + ON UPDATE CASCADE ON DELETE CASCADE +); diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d new file mode 100644 index 0000000..2a20544 --- /dev/null +++ b/finnow-api/source/account/api.d @@ -0,0 +1,84 @@ +module account.api; + +import handy_httpd; +import asdf; + +import profile.service; +import account.model; +import money.currency; + +struct AccountResponse { + ulong id; + string createdAt; + bool archived; + string type; + string numberSuffix; + string name; + string currency; + string description; + + static AccountResponse of(in Account account) { + 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.code.dup; + r.description = account.description; + return r; + } +} + +void handleGetAccounts(ref HttpRequestContext ctx) { + import std.algorithm; + auto ds = getProfileDataSource(ctx); + auto accounts = ds.getAccountRepository().findAll() + .map!(a => AccountResponse.of(a)); + ctx.response.writeBodyString(serializeToJson(accounts), "application/json"); +} + +void handleGetAccount(ref HttpRequestContext ctx) { + ulong accountId = ctx.request.getPathParamAs!ulong("accountId"); + auto ds = getProfileDataSource(ctx); + auto account = ds.getAccountRepository().findById(accountId) + .orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); + ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json"); +} + +struct AccountCreationPayload { + string type; + string numberSuffix; + string name; + string currency; + string description; +} + +void handleCreateAccount(ref HttpRequestContext ctx) { + auto ds = getProfileDataSource(ctx); + AccountCreationPayload payload; + try { + payload = deserialize!(AccountCreationPayload)(ctx.request.readBodyAsString()); + } catch (SerdeException e) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid account payload."); + return; + } + AccountType type = AccountType.fromId(payload.type); + Currency currency = Currency.ofCode(payload.currency); + Account account = ds.getAccountRepository().insert( + type, + payload.numberSuffix, + payload.name, + currency, + payload.description + ); + ctx.response.writeBodyString(serializeToJson(AccountResponse.of(account)), "application/json"); +} + +void handleDeleteAccount(ref HttpRequestContext ctx) { + ulong accountId = ctx.request.getPathParamAs!ulong("accountId"); + auto ds = getProfileDataSource(ctx); + ds.getAccountRepository().deleteById(accountId); +} diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d new file mode 100644 index 0000000..0f68af0 --- /dev/null +++ b/finnow-api/source/account/data.d @@ -0,0 +1,19 @@ +module account.data; + +import handy_httpd.components.optional; + +import account.model; +import money.currency; +import history.model; + +interface AccountRepository { + Optional!Account findById(ulong id); + Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description); + void setArchived(ulong id, bool archived); + Account update(ulong id, in Account newData); + void deleteById(ulong id); + Account[] findAll(); + AccountCreditCardProperties getCreditCardProperties(ulong id); + void setCreditCardProperties(ulong id, in AccountCreditCardProperties props); + History getHistory(ulong id); +} diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d new file mode 100644 index 0000000..e37b2b4 --- /dev/null +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -0,0 +1,138 @@ +module account.data_impl_sqlite; + +import std.datetime; + +import d2sqlite3; +import handy_httpd.components.optional; + +import account.data; +import account.model; +import money.currency; +import history.model; +import util.sqlite; + +class SqliteAccountRepository : AccountRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!Account findById(ulong id) { + return findOne( + db, + "SELECT * FROM account WHERE id = ?", + &parseAccount, + id + ); + } + + Account insert(AccountType type, string numberSuffix, string name, Currency currency, string description) { + Statement stmt = db.prepare(q"SQL + INSERT INTO account + (created_at, type, number_suffix, name, currency, description) + VALUES (?, ?, ?, ?, ?, ?) +SQL"); + stmt.bind(1, Clock.currTime(UTC()).toISOExtString()); + stmt.bind(2, type.id); + stmt.bind(3, numberSuffix); + stmt.bind(4, name); + stmt.bind(5, currency.code); + stmt.bind(6, description); + stmt.execute(); + ulong accountId = db.lastInsertRowid(); + return findById(accountId).orElseThrow("Couldn't find account!"); + } + + void setArchived(ulong id, bool archived) { + util.sqlite.update( + db, + "UPDATE account SET archived = ? WHERE id = ?", + archived ? 1 : 0, + id + ); + } + + Account update(ulong id, in Account newData) { + return newData; // TODO: + } + + void deleteById(ulong id) { + doTransaction(db, () { + util.sqlite.update( + db, + "DELETE FROM history + WHERE id IN ( + SELECT history_id FROM account_history + WHERE account_id = ? + )", + id + ); + util.sqlite.update(db, "DELETE FROM account WHERE id = ?", id); + }); + } + + Account[] findAll() { + return util.sqlite.findAll(db, "SELECT * FROM account", &parseAccount); + } + + AccountCreditCardProperties getCreditCardProperties(ulong id) { + Statement stmt = db.prepare("SELECT * FROM account_credit_card_properties WHERE account_id = ?"); + stmt.bind(1, id); + ResultRange result = stmt.execute(); + return parseCreditCardProperties(result.front); + } + + void setCreditCardProperties(ulong id, in AccountCreditCardProperties props) { + // TODO: + } + + 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( + 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 + ); + if (!history.empty) { + return history.value; + } + // 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); + } + + static Account parseAccount(Row row) { + return Account( + row.peek!ulong(0), + SysTime.fromISOExtString(row.peek!string(1)), + row.peek!bool(2), + AccountType.fromId(row.peek!string(3)), + row.peek!string(4), + row.peek!string(5), + Currency.ofCode(row.peek!string(6)), + row.peek!string(7) + ); + } + + static AccountCreditCardProperties parseCreditCardProperties(Row row) { + import std.typecons : Nullable; + ulong accountId = row.peek!ulong(0); + Nullable!ulong creditLimit = row.peek!ulong(1); + return AccountCreditCardProperties( + accountId, + creditLimit.isNull ? -1 : creditLimit.get() + ); + } +} diff --git a/finnow-api/source/account/model.d b/finnow-api/source/account/model.d new file mode 100644 index 0000000..cadcc6b --- /dev/null +++ b/finnow-api/source/account/model.d @@ -0,0 +1,42 @@ +module account.model; + +import std.datetime; + +import money.currency; + +struct AccountType { + const string id; + const string name; + const bool debitsPositive; + + import std.traits : EnumMembers; + static AccountType fromId(string id) { + static foreach (t; EnumMembers!AccountTypes) { + if (id == t.id) return t; + } + throw new Exception("Invalid account type id " ~ id); + } +} + +enum AccountTypes : AccountType { + CHECKING = AccountType("CHECKING", "Checking", true), + SAVINGS = AccountType("SAVINGS", "Savings", true), + CREDIT_CARD = AccountType("CREDIT_CARD", "Credit Card", false), + BROKERAGE = AccountType("BROKERAGE", "Brokerage", true) +} + +struct Account { + ulong id; + SysTime createdAt; + bool archived; + AccountType type; + string numberSuffix; + string name; + Currency currency; + string description; +} + +struct AccountCreditCardProperties { + ulong account_id; + long creditLimit; +} diff --git a/finnow-api/source/api_mapping.d b/finnow-api/source/api_mapping.d new file mode 100644 index 0000000..71703ef --- /dev/null +++ b/finnow-api/source/api_mapping.d @@ -0,0 +1,58 @@ +module api_mapping; + +import handy_httpd; +import handy_httpd.handlers.path_handler; +import handy_httpd.handlers.filtered_handler; + +/** + * Defines the Finnow API mapping with a main PathHandler. + * Returns: The handler to plug into an HttpServer. + */ +PathHandler mapApiHandlers() { + /// The base path to all API endpoints. + const API_PATH = "/api"; + PathHandler h = new PathHandler(); + + // Generic, public endpoints: + h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus); + h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions); + + // Auth endpoints: + import auth.api; + h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin); + h.addMapping(Method.POST, API_PATH ~ "/register", &postRegister); + + // Authenticated endpoints: + PathHandler a = new PathHandler(); + a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); + + import profile.api; + a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles); + a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile); + /// URL path to a specific profile, with the :profile path parameter. + const PROFILE_PATH = API_PATH ~ "/profiles/:profile"; + a.addMapping(Method.DELETE, PROFILE_PATH, &handleDeleteProfile); + a.addMapping(Method.GET, PROFILE_PATH ~ "/properties", &handleGetProperties); + + import account.api; + a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts", &handleGetAccounts); + a.addMapping(Method.POST, PROFILE_PATH ~ "/accounts", &handleCreateAccount); + a.addMapping(Method.GET, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleGetAccount); + a.addMapping(Method.DELETE, PROFILE_PATH ~ "/accounts/:accountId:ulong", &handleDeleteAccount); + + // Protect all authenticated paths with a token filter. + import auth.service : TokenAuthenticationFilter, SECRET; + HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET); + h.addMapping(API_PATH ~ "/**", new FilteredRequestHandler( + a, + [tokenAuthenticationFilter] + )); + + return h; +} + +private void getStatus(ref HttpRequestContext ctx) { + ctx.response.writeBodyString("online"); +} + +private void getOptions(ref HttpRequestContext ctx) {} diff --git a/finnow-api/source/app.d b/finnow-api/source/app.d index 0c59fa1..3aaff62 100644 --- a/finnow-api/source/app.d +++ b/finnow-api/source/app.d @@ -1,50 +1,10 @@ -import slf4d; import handy_httpd; -import handy_httpd.handlers.path_handler; -import handy_httpd.handlers.filtered_handler; +import api_mapping; void main() { ServerConfig cfg; cfg.workerPoolSize = 5; cfg.port = 8080; - HttpServer server = new HttpServer(buildHandlers(), cfg); + HttpServer server = new HttpServer(mapApiHandlers(), cfg); server.start(); } - -PathHandler buildHandlers() { - import profile; - - const API_PATH = "/api"; - PathHandler pathHandler = new PathHandler(); - - // Generic, public endpoints: - pathHandler.addMapping(Method.GET, API_PATH ~ "/status", (ref ctx) { - ctx.response.writeBodyString("online"); - }); - pathHandler.addMapping(Method.OPTIONS, API_PATH ~ "/**", (ref ctx) {}); - - // Auth Entrypoints: - import auth.api; - import auth.service; - pathHandler.addMapping(Method.POST, API_PATH ~ "/login", &postLogin); - pathHandler.addMapping(Method.POST, API_PATH ~ "/register", &postRegister); - - // Authenticated endpoints: - PathHandler a = new PathHandler(); - a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser); - a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles); - a.addMapping(Method.POST, API_PATH ~ "/profiles", &handleCreateNewProfile); - a.addMapping(Method.DELETE, API_PATH ~ "/profiles/:name", &handleDeleteProfile); - a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/properties", &handleGetProperties); - a.addMapping(Method.GET, API_PATH ~ "/profiles/:profile/accounts", (ref ctx) { - ctx.response.writeBodyString("your accounts!"); - }); - - HttpRequestFilter tokenAuthenticationFilter = new TokenAuthenticationFilter(SECRET); - pathHandler.addMapping(API_PATH ~ "/**", new FilteredRequestHandler( - a, - [tokenAuthenticationFilter] - )); - - return pathHandler; -} diff --git a/finnow-api/source/model/base.d b/finnow-api/source/attachment/model.d similarity index 65% rename from finnow-api/source/model/base.d rename to finnow-api/source/attachment/model.d index b0f033e..97c5461 100644 --- a/finnow-api/source/model/base.d +++ b/finnow-api/source/attachment/model.d @@ -1,12 +1,7 @@ -module model.base; +module attachment.model; import std.datetime; -struct ProfileProperty { - string property; - string value; -} - struct Attachment { ulong id; SysTime uploadedAt; diff --git a/finnow-api/source/auth/api.d b/finnow-api/source/auth/api.d index 0806db1..f5f5d8b 100644 --- a/finnow-api/source/auth/api.d +++ b/finnow-api/source/auth/api.d @@ -4,16 +4,17 @@ module auth.api; import handy_httpd; import handy_httpd.components.optional; import slf4d; +import asdf; import auth.model; -import auth.dao; -import auth.dto; +import auth.data; import auth.service; +import auth.data_impl_fs; void postLogin(ref HttpRequestContext ctx) { LoginCredentials loginCredentials; try { - loginCredentials = LoginCredentials.parse(ctx.request.readBodyAsJson()); + loginCredentials = deserialize!(LoginCredentials)(ctx.request.readBodyAsString()); } catch (Exception e) { ctx.response.status = HttpStatus.BAD_REQUEST; } @@ -34,13 +35,14 @@ void postLogin(ref HttpRequestContext ctx) { } string token = generateAccessToken(optionalUser.value); ctx.response.status = HttpStatus.OK; - ctx.response.writeBodyString(TokenResponse(token).toJson(), "application/json"); + TokenResponse resp = TokenResponse(token); + ctx.response.writeBodyString(serializeToJson(resp), "application/json"); } void postRegister(ref HttpRequestContext ctx) { RegistrationData registrationData; try { - registrationData = RegistrationData.parse(ctx.request.readBodyAsJson()); + registrationData = deserialize!(RegistrationData)(ctx.request.readBodyAsString()); } catch (Exception e) { ctx.response.status = HttpStatus.BAD_REQUEST; return; diff --git a/finnow-api/source/auth/data.d b/finnow-api/source/auth/data.d new file mode 100644 index 0000000..086b8bd --- /dev/null +++ b/finnow-api/source/auth/data.d @@ -0,0 +1,26 @@ +module auth.data; + +import handy_httpd.components.optional; +import auth.model; + +/// The credentials provided by a user to login. +struct LoginCredentials { + string username; + string password; +} + +/// A token response sent to the user if they've been authenticated. +struct TokenResponse { + string token; +} + +struct RegistrationData { + string username; + string password; +} + +interface UserRepository { + Optional!User findByUsername(string username); + User createUser(string username, string passwordHash); + void deleteByUsername(string username); +} diff --git a/finnow-api/source/auth/dao.d b/finnow-api/source/auth/data_impl_fs.d similarity index 90% rename from finnow-api/source/auth/dao.d rename to finnow-api/source/auth/data_impl_fs.d index c10e224..f63cb53 100644 --- a/finnow-api/source/auth/dao.d +++ b/finnow-api/source/auth/data_impl_fs.d @@ -1,13 +1,9 @@ -module auth.dao; +module auth.data_impl_fs; import handy_httpd.components.optional; -import auth.model; -interface UserRepository { - Optional!User findByUsername(string username); - User createUser(string username, string passwordHash); - void deleteByUsername(string username); -} +import auth.data; +import auth.model; /** * User implementation that stores each user's data in a separate directory. @@ -63,4 +59,4 @@ class FileSystemUserRepository : UserRepository { private string getUserDataFile(string username) { return buildPath(this.usersDir, username, "user-data.json"); } -} +} \ No newline at end of file diff --git a/finnow-api/source/auth/dto.d b/finnow-api/source/auth/dto.d deleted file mode 100644 index 798fbf2..0000000 --- a/finnow-api/source/auth/dto.d +++ /dev/null @@ -1,46 +0,0 @@ -/// Defines data-transfer objects for the API's authentication mechanisms. -module auth.dto; - -import handy_httpd; -import std.json; - -struct LoginCredentials { - string username; - string password; - - static LoginCredentials parse(JSONValue obj) { - if ( - obj.type != JSONType.OBJECT || - "username" !in obj.object || - "password" !in obj.object || - obj.object["username"].type != JSONType.STRING || - obj.object["password"].type != JSONType.STRING - ) { - throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Malformed login credentials."); - } - return LoginCredentials( - obj.object["username"].str, - obj.object["password"].str - ); - } -} - -struct TokenResponse { - string token; - - string toJson() { - JSONValue obj = JSONValue.emptyObject; - obj.object["token"] = JSONValue(token); - return obj.toString(); - } -} - -struct RegistrationData { - string username; - string password; - - static RegistrationData parse(JSONValue obj) { - LoginCredentials lc = LoginCredentials.parse(obj); - return RegistrationData(lc.username, lc.password); - } -} \ No newline at end of file diff --git a/finnow-api/source/auth/service.d b/finnow-api/source/auth/service.d index c26ab48..a0a455f 100644 --- a/finnow-api/source/auth/service.d +++ b/finnow-api/source/auth/service.d @@ -2,11 +2,12 @@ module auth.service; import handy_httpd; import handy_httpd.components.optional; +import handy_httpd.handlers.filtered_handler; import slf4d; import auth.model; -import auth.dao; -import handy_httpd.handlers.filtered_handler; +import auth.data; +import auth.data_impl_fs; const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config! @@ -66,7 +67,7 @@ class AuthContext { * ctx = The request context to get. * Returns: The auth context that has been set. */ -AuthContext getAuthContext(ref HttpRequestContext ctx) { +AuthContext getAuthContext(in HttpRequestContext ctx) { return cast(AuthContext) ctx.metadata[TokenAuthenticationFilter.AUTH_METADATA_KEY]; } diff --git a/finnow-api/source/data/account.d b/finnow-api/source/data/account.d deleted file mode 100644 index 0ee7170..0000000 --- a/finnow-api/source/data/account.d +++ /dev/null @@ -1,2 +0,0 @@ -module data.account; - diff --git a/finnow-api/source/data/base.d b/finnow-api/source/data/base.d deleted file mode 100644 index 248adfd..0000000 --- a/finnow-api/source/data/base.d +++ /dev/null @@ -1,15 +0,0 @@ -module data.base; - -import model; -import handy_httpd.components.optional; - -interface DataSource { - PropertiesRepository getPropertiesRepository(); -} - -interface PropertiesRepository { - Optional!string findProperty(string propertyName); - void setProperty(string name, string value); - void deleteProperty(string name); - ProfileProperty[] findAll(); -} diff --git a/finnow-api/source/data/package.d b/finnow-api/source/data/package.d deleted file mode 100644 index 597bcbd..0000000 --- a/finnow-api/source/data/package.d +++ /dev/null @@ -1,6 +0,0 @@ -module data; - -public import data.base; - -// Utility imports for items commonly used alongside data. -public import handy_httpd.components.optional; diff --git a/finnow-api/source/data/sqlite.d b/finnow-api/source/data/sqlite.d deleted file mode 100644 index 92e56b7..0000000 --- a/finnow-api/source/data/sqlite.d +++ /dev/null @@ -1,82 +0,0 @@ -module data.sqlite; - -import d2sqlite3; -import slf4d; -import data.base; -import model; - -class SqliteDataSource : DataSource { - const SCHEMA = import("schema.sql"); - - private const string dbPath; - private Database db; - - this(string path) { - this.dbPath = path; - import std.file : exists; - bool needsInit = !exists(path); - this.db = Database(path); - if (needsInit) { - infoF!"Initializing database: %s"(dbPath); - db.run(SCHEMA); - } - } - - PropertiesRepository getPropertiesRepository() { - return new SqlitePropertiesRepository(db); - } -} - -class SqliteRepository { - private Database db; - this(Database db) { - this.db = db; - } -} - -class SqlitePropertiesRepository : SqliteRepository, PropertiesRepository { - this(Database db) { - super(db); - } - - Optional!string findProperty(string propertyName) { - Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?"); - stmt.bind(1, propertyName); - ResultRange result = stmt.execute(); - if (result.empty) return Optional!string.empty; - return Optional!string.of(result.front.peek!string(0)); - } - - void setProperty(string name, string value) { - if (findProperty(name).isNull) { - Statement stmt = this.db.prepare("INSERT INTO profile_property (property, value) VALUES (?, ?)"); - stmt.bind(1, name); - stmt.bind(2, value); - stmt.execute(); - } else { - Statement stmt = this.db.prepare("UPDATE profile_property SET value = ? WHERE property = ?"); - stmt.bind(1, value); - stmt.bind(2, name); - stmt.execute(); - } - } - - void deleteProperty(string name) { - Statement stmt = this.db.prepare("DELETE FROM profile_property WHERE property = ?"); - stmt.bind(1, name); - stmt.execute(); - } - - ProfileProperty[] findAll() { - Statement stmt = this.db.prepare("SELECT * FROM profile_property ORDER BY property ASC"); - ResultRange result = stmt.execute(); - ProfileProperty[] props; - foreach (Row row; result) { - ProfileProperty prop; - prop.property = row.peek!string("property"); - prop.value = row.peek!string("value"); - props ~= prop; - } - return props; - } -} diff --git a/finnow-api/source/history/data.d b/finnow-api/source/history/data.d new file mode 100644 index 0000000..94a4c44 --- /dev/null +++ b/finnow-api/source/history/data.d @@ -0,0 +1,14 @@ +module history.data; + +import std.datetime; + +import handy_httpd.components.optional; + +import history.model; + +interface HistoryRepository { + Optional!History findById(ulong id); + HistoryItem[] findItemsBefore(ulong historyId, SysTime timestamp, uint limit); + HistoryItemText getTextItem(ulong itemId); + 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 new file mode 100644 index 0000000..b4f1ffd --- /dev/null +++ b/finnow-api/source/history/data_impl_sqlite.d @@ -0,0 +1,75 @@ +module history.data_impl_sqlite; + +import std.datetime; + +import handy_httpd.components.optional; +import d2sqlite3; + +import history.data; +import history.model; + +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 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) { + HistoryItem item; + item.id = row.peek!ulong(0); + item.historyId = row.peek!ulong(1); + item.timestamp = SysTime.fromISOExtString(row.peek!string(2)); + item.type = getHistoryItemType(row.peek!string(3)); + return item; + } + + static HistoryItemText parseTextItem(Row row) { + HistoryItemText item; + item.itemId = row.peek!ulong(0); + item.content = row.peek!string(1); + return item; + } +} \ No newline at end of file diff --git a/finnow-api/source/history/model.d b/finnow-api/source/history/model.d new file mode 100644 index 0000000..5ba850e --- /dev/null +++ b/finnow-api/source/history/model.d @@ -0,0 +1,31 @@ +module history.model; + +import std.datetime.systime; + +struct History { + ulong id; +} + +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); +} + +struct HistoryItem { + ulong id; + ulong historyId; + SysTime timestamp; + HistoryItemType type; +} + +struct HistoryItemText { + ulong itemId; + string content; +} diff --git a/finnow-api/source/model/account.d b/finnow-api/source/model/account.d deleted file mode 100644 index 3b6fd8e..0000000 --- a/finnow-api/source/model/account.d +++ /dev/null @@ -1,20 +0,0 @@ -module model.account; - -import model.base; -import std.datetime; - -struct Account { - ulong id; - SysTime createdAt; - bool archived; - string type; - string numberSuffix; - string name; - string currency; - string description; -} - -struct AccountCreditCardProperties { - ulong account_id; - string creditLimit; -} diff --git a/finnow-api/source/model/package.d b/finnow-api/source/model/package.d deleted file mode 100644 index fbc32a3..0000000 --- a/finnow-api/source/model/package.d +++ /dev/null @@ -1,8 +0,0 @@ -module model; - -public import model.base; -public import model.account; -public import model.transaction; - -// Additional utility imports used often alongside models. -public import handy_httpd.components.optional; \ No newline at end of file diff --git a/finnow-api/source/model/currency.d b/finnow-api/source/money/currency.d similarity index 97% rename from finnow-api/source/model/currency.d rename to finnow-api/source/money/currency.d index 8d9ba75..57ad708 100644 --- a/finnow-api/source/model/currency.d +++ b/finnow-api/source/money/currency.d @@ -1,4 +1,4 @@ -module model.currency; +module money.currency; struct Currency { const char[3] code; diff --git a/finnow-api/source/profile.d b/finnow-api/source/profile.d deleted file mode 100644 index a763e2b..0000000 --- a/finnow-api/source/profile.d +++ /dev/null @@ -1,214 +0,0 @@ -/** - * This module contains everything related to a user's Profiles, including - * data repositories, API endpoints, models, and logic. - */ -module profile; - -import handy_httpd; -import handy_httpd.components.optional; -import auth.model : User; -import auth.service : AuthContext, getAuthContext; -import data; -import model; - -import std.json; -import asdf; - -const DEFAULT_USERS_DIR = "users"; - -/** - * A profile is a complete set of Finnow financial data, all stored in one - * single database file. The profile's name is used to lookup the database - * partition for its data. A user may own multiple profiles. - */ -class Profile { - string name; - - this(string name) { - this.name = name; - } - - override int opCmp(Object other) const { - if (Profile p = cast(Profile) other) { - return this.name < p.name; - } - return 1; - } -} - -/** - * Validates a profile name. - * Params: - * name = The name to check. - * Returns: True if the profile name is valid. - */ -bool validateProfileName(string name) { - import std.regex; - import std.uni : toLower; - if (name is null || name.length < 3) return false; - auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`); - return !matchFirst(name, r).empty; -} - -interface ProfileRepository { - Optional!Profile findByName(string name); - Profile createProfile(string name); - Profile[] findAll(); - void deleteByName(string name); - DataSource getDataSource(in Profile profile); -} - -class FileSystemProfileRepository : ProfileRepository { - import std.path; - import std.file; - import data; - import data.sqlite; - - private const string usersDir; - private const string username; - - this(string usersDir, string username) { - this.usersDir = usersDir; - this.username = username; - } - - this(string username) { - this(DEFAULT_USERS_DIR, username); - } - - Optional!Profile findByName(string name) { - string path = getProfilePath(name); - if (!exists(path)) return Optional!Profile.empty; - return Optional!Profile.of(new Profile(name)); - } - - Profile createProfile(string name) { - string path = getProfilePath(name); - if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists."); - if (!exists(getProfilesDir())) mkdir(getProfilesDir()); - DataSource ds = new SqliteDataSource(path); - import std.datetime; - auto propsRepo = ds.getPropertiesRepository(); - propsRepo.setProperty("name", name); - propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString()); - propsRepo.setProperty("user", username); - return new Profile(name); - } - - Profile[] findAll() { - string profilesDir = getProfilesDir(); - if (!exists(profilesDir)) return []; - Profile[] profiles; - foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) { - import std.string : endsWith; - const suffix = ".sqlite"; - if (endsWith(entry.name, suffix)) { - string profileName = baseName(entry.name, suffix); - profiles ~= new Profile(profileName); - } - } - import std.algorithm.sorting : sort; - sort(profiles); - return profiles; - } - - void deleteByName(string name) { - string path = getProfilePath(name); - if (exists(path)) { - std.file.remove(path); - } - } - - DataSource getDataSource(in Profile profile) { - return new SqliteDataSource(getProfilePath(profile.name)); - } - - private string getProfilesDir() { - return buildPath(this.usersDir, username, "profiles"); - } - - private string getProfilePath(string name) { - return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite"); - } -} - -// API Endpoints Below Here! - -void handleCreateNewProfile(ref HttpRequestContext ctx) { - JSONValue obj = ctx.request.readBodyAsJson(); - string name = obj.object["name"].str; - if (!validateProfileName(name)) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid profile name."); - return; - } - AuthContext auth = getAuthContext(ctx); - ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); - profileRepo.createProfile(name); -} - -void handleGetProfiles(ref HttpRequestContext ctx) { - AuthContext auth = getAuthContext(ctx); - ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); - Profile[] profiles = profileRepo.findAll(); - ctx.response.writeBodyString(serializeToJson(profiles), "application/json"); -} - -void handleDeleteProfile(ref HttpRequestContext ctx) { - string name = ctx.request.getPathParamAs!string("name"); - if (!validateProfileName(name)) { - ctx.response.status = HttpStatus.BAD_REQUEST; - ctx.response.writeBodyString("Invalid profile name."); - return; - } - AuthContext auth = getAuthContext(ctx); - ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); - profileRepo.deleteByName(name); -} - -void handleGetProperties(ref HttpRequestContext ctx) { - ProfileContext profileCtx = getProfileContextOrThrow(ctx); - ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); - DataSource ds = profileRepo.getDataSource(profileCtx.profile); - auto propsRepo = ds.getPropertiesRepository(); - ProfileProperty[] props = propsRepo.findAll(); - ctx.response.writeBodyString(serializeToJson(props), "application/json"); -} - -/// Contextual information that's available when handling requests under a profile. -struct ProfileContext { - const Profile profile; - const User user; -} - -/** - * Tries to get a profile context from a request context. This will attempt to - * extract a "profile" path parameter and the authenticated user, and combine - * them into the ProfileContext. - * Params: - * ctx = The request context to read. - * Returns: An optional profile context. - */ -Optional!ProfileContext getProfileContext(ref HttpRequestContext ctx) { - import auth.service : AuthContext, getAuthContext; - if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty; - string profileName = ctx.request.pathParams["profile"]; - if (!validateProfileName(profileName)) return Optional!ProfileContext.empty; - AuthContext authCtx = getAuthContext(ctx); - if (authCtx is null) return Optional!ProfileContext.empty; - User user = authCtx.user; - ProfileRepository repo = new FileSystemProfileRepository("users", user.username); - return repo.findByName(profileName) - .mapIfPresent!(p => ProfileContext(p, user)); -} - -/** - * Similar to `getProfileContext`, but throws an HttpStatusException with a - * 404 NOT FOUND status if no profile context could be obtained. - * Params: - * ctx = The request context to read. - * Returns: The profile context that was obtained. - */ -ProfileContext getProfileContextOrThrow(ref HttpRequestContext ctx) { - return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); -} diff --git a/finnow-api/source/profile/api.d b/finnow-api/source/profile/api.d new file mode 100644 index 0000000..27c9001 --- /dev/null +++ b/finnow-api/source/profile/api.d @@ -0,0 +1,54 @@ +module profile.api; + +import std.json; + +import handy_httpd; +import asdf; + +import profile.model; +import profile.service; +import profile.data; +import profile.data_impl_sqlite; +import auth.model; +import auth.service; + +void handleCreateNewProfile(ref HttpRequestContext ctx) { + JSONValue obj = ctx.request.readBodyAsJson(); + string name = obj.object["name"].str; + if (!validateProfileName(name)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid profile name."); + return; + } + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + profileRepo.createProfile(name); +} + +void handleGetProfiles(ref HttpRequestContext ctx) { + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + Profile[] profiles = profileRepo.findAll(); + ctx.response.writeBodyString(serializeToJson(profiles), "application/json"); +} + +void handleDeleteProfile(ref HttpRequestContext ctx) { + string name = ctx.request.getPathParamAs!string("name"); + if (!validateProfileName(name)) { + ctx.response.status = HttpStatus.BAD_REQUEST; + ctx.response.writeBodyString("Invalid profile name."); + return; + } + AuthContext auth = getAuthContext(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(auth.user.username); + profileRepo.deleteByName(name); +} + +void handleGetProperties(ref HttpRequestContext ctx) { + ProfileContext profileCtx = getProfileContextOrThrow(ctx); + ProfileRepository profileRepo = new FileSystemProfileRepository(profileCtx.user.username); + ProfileDataSource ds = profileRepo.getDataSource(profileCtx.profile); + auto propsRepo = ds.getPropertiesRepository(); + ProfileProperty[] props = propsRepo.findAll(); + ctx.response.writeBodyString(serializeToJson(props), "application/json"); +} \ No newline at end of file diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d new file mode 100644 index 0000000..7e6ef1f --- /dev/null +++ b/finnow-api/source/profile/data.d @@ -0,0 +1,30 @@ +module profile.data; + +import handy_httpd.components.optional; +import profile.model; + +interface ProfileRepository { + Optional!Profile findByName(string name); + Profile createProfile(string name); + Profile[] findAll(); + void deleteByName(string name); + ProfileDataSource getDataSource(in Profile profile); +} + +interface PropertiesRepository { + Optional!string findProperty(string propertyName); + void setProperty(string name, string value); + void deleteProperty(string name); + ProfileProperty[] findAll(); +} + +/** + * A data source for all data contained within a profile. This serves as the + * gateway for all data access operations for a profile. + */ +interface ProfileDataSource { + import account.data : AccountRepository; + + PropertiesRepository getPropertiesRepository(); + AccountRepository getAccountRepository(); +} diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d new file mode 100644 index 0000000..7a4250a --- /dev/null +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -0,0 +1,171 @@ +module profile.data_impl_sqlite; + +import slf4d; +import handy_httpd.components.optional; +import handy_httpd.components.handler : HttpStatusException; +import handy_httpd.components.response : HttpStatus; +import d2sqlite3; + +import profile.data; +import profile.model; +import util.sqlite; + +const DEFAULT_USERS_DIR = "users"; + +class FileSystemProfileRepository : ProfileRepository { + import std.path; + import std.file; + + private const string usersDir; + private const string username; + + this(string usersDir, string username) { + this.usersDir = usersDir; + this.username = username; + } + + this(string username) { + this(DEFAULT_USERS_DIR, username); + } + + Optional!Profile findByName(string name) { + string path = getProfilePath(name); + if (!exists(path)) return Optional!Profile.empty; + return Optional!Profile.of(new Profile(name)); + } + + Profile createProfile(string name) { + string path = getProfilePath(name); + if (exists(path)) throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Profile already exists."); + if (!exists(getProfilesDir())) mkdir(getProfilesDir()); + ProfileDataSource ds = new SqliteProfileDataSource(path); + import std.datetime; + auto propsRepo = ds.getPropertiesRepository(); + propsRepo.setProperty("name", name); + propsRepo.setProperty("createdAt", Clock.currTime(UTC()).toISOExtString()); + propsRepo.setProperty("user", username); + return new Profile(name); + } + + Profile[] findAll() { + string profilesDir = getProfilesDir(); + if (!exists(profilesDir)) return []; + Profile[] profiles; + foreach (DirEntry entry; dirEntries(profilesDir, SpanMode.shallow, false)) { + import std.string : endsWith; + const suffix = ".sqlite"; + if (endsWith(entry.name, suffix)) { + string profileName = baseName(entry.name, suffix); + profiles ~= new Profile(profileName); + } + } + import std.algorithm.sorting : sort; + sort(profiles); + return profiles; + } + + void deleteByName(string name) { + string path = getProfilePath(name); + if (exists(path)) { + std.file.remove(path); + } + } + + ProfileDataSource getDataSource(in Profile profile) { + return new SqliteProfileDataSource(getProfilePath(profile.name)); + } + + private string getProfilesDir() { + return buildPath(this.usersDir, username, "profiles"); + } + + private string getProfilePath(string name) { + return buildPath(this.usersDir, username, "profiles", name ~ ".sqlite"); + } +} + +class SqlitePropertiesRepository : PropertiesRepository { + private Database db; + this(Database db) { + this.db = db; + } + + Optional!string findProperty(string propertyName) { + return findOne( + db, + "SELECT value FROM profile_property WHERE property = ?", + r => r.peek!string(0), + propertyName + ); + // Statement stmt = this.db.prepare("SELECT value FROM profile_property WHERE property = ?"); + // stmt.bind(1, propertyName); + // ResultRange result = stmt.execute(); + // if (result.empty) return Optional!string.empty; + // return Optional!string.of(result.front.peek!string(0)); + } + + void setProperty(string name, string value) { + if (findProperty(name).isNull) { + Statement stmt = this.db.prepare("INSERT INTO profile_property (property, value) VALUES (?, ?)"); + stmt.bind(1, name); + stmt.bind(2, value); + stmt.execute(); + } else { + Statement stmt = this.db.prepare("UPDATE profile_property SET value = ? WHERE property = ?"); + stmt.bind(1, value); + stmt.bind(2, name); + stmt.execute(); + } + } + + void deleteProperty(string name) { + Statement stmt = this.db.prepare("DELETE FROM profile_property WHERE property = ?"); + stmt.bind(1, name); + stmt.execute(); + } + + ProfileProperty[] findAll() { + Statement stmt = this.db.prepare("SELECT * FROM profile_property ORDER BY property ASC"); + ResultRange result = stmt.execute(); + ProfileProperty[] props; + foreach (Row row; result) { + ProfileProperty prop; + prop.property = row.peek!string("property"); + prop.value = row.peek!string("value"); + props ~= prop; + } + return props; + } +} + +/** + * An SQLite implementation of the ProfileDataSource that uses a single + * database connection to initialize various entity data access objects lazily. + */ +class SqliteProfileDataSource : ProfileDataSource { + import account.data; + import account.data_impl_sqlite; + + const SCHEMA = import("schema.sql"); + private const string dbPath; + private Database db; + + this(string path) { + this.dbPath = path; + import std.file : exists; + bool needsInit = !exists(path); + this.db = Database(path); + if (needsInit) { + infoF!"Initializing database: %s"(dbPath); + db.run(SCHEMA); + } + } + + PropertiesRepository getPropertiesRepository() { + return new SqlitePropertiesRepository(db); + } + + AccountRepository getAccountRepository() { + return new SqliteAccountRepository(db); + } +} diff --git a/finnow-api/source/profile/model.d b/finnow-api/source/profile/model.d new file mode 100644 index 0000000..7952f8a --- /dev/null +++ b/finnow-api/source/profile/model.d @@ -0,0 +1,28 @@ +module profile.model; + +import profile.data; + +/** + * A profile is a complete set of Finnow financial data, all stored in one + * single database file. The profile's name is used to lookup the database + * partition for its data. A user may own multiple profiles. + */ +class Profile { + string name; + + this(string name) { + this.name = name; + } + + override int opCmp(Object other) const { + if (Profile p = cast(Profile) other) { + return this.name < p.name; + } + return 1; + } +} + +struct ProfileProperty { + string property; + string value; +} diff --git a/finnow-api/source/profile/package.d b/finnow-api/source/profile/package.d new file mode 100644 index 0000000..c087ff7 --- /dev/null +++ b/finnow-api/source/profile/package.d @@ -0,0 +1,6 @@ +/** + * This module defines all components pertaining to "Profiles", that is, + * isolated Finnow databases that belong to a single user. Each profile is + * completely separate from all others. + */ +module profile; diff --git a/finnow-api/source/profile/service.d b/finnow-api/source/profile/service.d new file mode 100644 index 0000000..3fec1ff --- /dev/null +++ b/finnow-api/source/profile/service.d @@ -0,0 +1,85 @@ +module profile.service; + +import handy_httpd; +import handy_httpd.components.optional; + +import profile.model; +import profile.data; +import profile.data_impl_sqlite; +import auth.model; + +/** + * Validates a profile name. + * Params: + * name = The name to check. + * Returns: True if the profile name is valid. + */ +bool validateProfileName(string name) { + import std.regex; + import std.uni : toLower; + if (name is null || name.length < 3) return false; + auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_]+$`); + return !matchFirst(name, r).empty; +} + +/// Contextual information that's available when handling requests under a profile. +struct ProfileContext { + const Profile profile; + const User user; +} + +/** + * Tries to get a profile context from a request context. This will attempt to + * extract a "profile" path parameter and the authenticated user, and combine + * them into the ProfileContext. + * Params: + * ctx = The request context to read. + * Returns: An optional profile context. + */ +Optional!ProfileContext getProfileContext(in HttpRequestContext ctx) { + import auth.service : AuthContext, getAuthContext; + if ("profile" !in ctx.request.pathParams) return Optional!ProfileContext.empty; + string profileName = ctx.request.pathParams["profile"]; + if (!validateProfileName(profileName)) return Optional!ProfileContext.empty; + AuthContext authCtx = getAuthContext(ctx); + if (authCtx is null) return Optional!ProfileContext.empty; + User user = authCtx.user; + ProfileRepository repo = new FileSystemProfileRepository(user.username); + return repo.findByName(profileName) + .mapIfPresent!(p => ProfileContext(p, user)); +} + +/** + * Similar to `getProfileContext`, but throws an HttpStatusException with a + * 404 NOT FOUND status if no profile context could be obtained. + * Params: + * ctx = The request context to read. + * Returns: The profile context that was obtained. + */ +ProfileContext getProfileContextOrThrow(in HttpRequestContext ctx) { + return getProfileContext(ctx).orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND)); +} + +/** + * Obtains a ProfileDataSource from a ProfileContext. Use this to get access + * to all profile data when handling an HTTP request, for example. + * Params: + * pc = The profile context. + * Returns: The profile data source. + */ +ProfileDataSource getProfileDataSource(in ProfileContext pc) { + ProfileRepository profileRepo = new FileSystemProfileRepository(pc.user.username); + return profileRepo.getDataSource(pc.profile); +} + +/** + * Obtains a ProfileDataSource from an HTTP request context. Use this to + * access all profile data when handling the request. + * Params: + * ctx = The request context. + * Returns: The profile data source. + */ +ProfileDataSource getProfileDataSource(in HttpRequestContext ctx) { + ProfileContext pc = getProfileContextOrThrow(ctx); + return getProfileDataSource(pc); +} diff --git a/finnow-api/source/model/transaction.d b/finnow-api/source/transaction/model.d similarity index 86% rename from finnow-api/source/model/transaction.d rename to finnow-api/source/transaction/model.d index a065bf7..3e0e65c 100644 --- a/finnow-api/source/model/transaction.d +++ b/finnow-api/source/transaction/model.d @@ -1,7 +1,8 @@ -module model.transaction; +module transaction.model; import std.datetime; -import model.currency; + +import money.currency; struct TransactionVendor { ulong id; @@ -25,9 +26,9 @@ struct Transaction { ulong id; SysTime timestamp; SysTime addedAt; - string amount; + ulong amount; Currency currency; string description; ulong vendorId; ulong categoryId; -} +} \ No newline at end of file diff --git a/finnow-api/source/util/sqlite.d b/finnow-api/source/util/sqlite.d new file mode 100644 index 0000000..e866383 --- /dev/null +++ b/finnow-api/source/util/sqlite.d @@ -0,0 +1,94 @@ +module util.sqlite; + +import slf4d; +import handy_httpd.components.optional; +import d2sqlite3; + +/** + * Tries to find a single row from a database. + * Params: + * db = The database to use. + * query = The query to execute. + * resultMapper = A function to map rows to the desired result type. + * args = Arguments for the query. + * Returns: An optional result. + */ +Optional!T findOne(T, Args...)(Database db, string query, T function(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)); +} + +/** + * Finds a list of records from a database. + * Params: + * db = The database to use. + * query = The query to execute. + * resultMapper = A function to map rows to the desired result type. + * args = Arguments for the query. + * Returns: A list of results. + */ +T[] findAll(T, Args...)(Database db, string query, T function(Row) resultMapper, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + import std.algorithm : map; + import std.array : array; + return stmt.execute().map!(r => resultMapper(r)).array; +} + +/** + * Determines if at least one record exists. + * Params: + * db = The database to use. + * query = The query to execute. + * args = The arguments for the query. + * Returns: True if at least one record is returned, or false if not. + */ +bool exists(Args...)(Database db, string query, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + return !stmt.execute().empty(); +} + +/** + * Performs an update (UPDATE/INSERT/DELETE). + * Params: + * db = The database to use. + * query = The query to execute. + * args = The arguments for the query. + * Returns: The number of rows that were affected. + */ +int update(Args...)(Database db, string query, Args args) { + Statement stmt = db.prepare(query); + stmt.bindAll(args); + stmt.execute(); + return db.changes(); +} + +/** + * Wraps a given delegate block of code in an SQL transaction, so that all + * operations will be committed at once when done. If an exception is thrown, + * then the changes will be rolled back. + * Params: + * db = The database to use. + * dg = The delegate block of code to run in the transaction. + * Returns: The return value of the delegate. + */ +T doTransaction(T)(Database db, T delegate() dg) { + try { + db.begin(); + static if (is(T : void)) { + dg(); + } else { + T result = dg(); + } + db.commit(); + static if (!is(T : void)) return result; + } catch (Exception e) { + error("Rolling back transaction due to exception.", e); + db.rollback(); + throw e; + } +}