Added bruno API thing, user account page, and user deletion logic.
This commit is contained in:
parent
270dcc6020
commit
f5c75ccf35
|
@ -8,8 +8,8 @@ This project is set up as a _modular monolith_, where the API as a whole is brok
|
|||
|
||||
Within each module, you'll usually find some of the following submodules:
|
||||
|
||||
* `model.d` - Defines models for this module, often database entities.
|
||||
* `model.d` - Defines models for this module, often database entities. Unless there's a **very** good reason for it, all entity attributes are marked as `const`. Entities are not mutable. They simply represent a view of what's in the database.
|
||||
* `data.d` - Defines the data access interfaces and associated types, so that other modules can interact with it.
|
||||
* `data_impl_*.d` - A concrete implementation of a submodule's data access interfaces, often using a specific technology or platform.
|
||||
* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework.
|
||||
* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules.
|
||||
* `api.d` - Defines any REST API endpoints that this module exposes to the web server framework, as well as data transfer objects that the API endpoints may consume or produce.
|
||||
* `service.d` - Defines business logic and associated types that may be called by the `api.d` submodule or other modules. This is where transactional business logic lives.
|
||||
|
|
|
@ -0,0 +1,29 @@
|
|||
meta {
|
||||
name: Create Account
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/profiles/:profile/accounts
|
||||
body: json
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
profile: {{current_profile}}
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"type": "CHECKING",
|
||||
"numberSuffix": "1234",
|
||||
"name": "Testing Checking",
|
||||
"currency": "USD",
|
||||
"description": "A test account."
|
||||
}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
meta {
|
||||
name: Delete Account
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{base_url}}/profiles/:profile/accounts/:accountId
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
profile: {{current_profile}}
|
||||
accountId: 1
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
meta {
|
||||
name: Get Account
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/profiles/:profile/accounts/:accountId
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
profile: {{current_profile}}
|
||||
accountId: 1
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Get Accounts
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/profiles/:name/accounts
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
name: {{profile}}
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: Delete My User
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{base_url}}/me
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Login
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/login
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"username": "{{username}}",
|
||||
"password": "{{password}}"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
meta {
|
||||
name: My User
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/me
|
||||
body: none
|
||||
auth: inherit
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Register
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/register
|
||||
body: json
|
||||
auth: none
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"username": "testuser",
|
||||
"password": "testpass"
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
meta {
|
||||
name: Generate Sample Data
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/sample-data
|
||||
body: none
|
||||
auth: none
|
||||
}
|
||||
|
||||
docs {
|
||||
Wipes and re-generates a set of sample accounts in the Finnow API. Only available in the development version.
|
||||
|
||||
The response is returned immediately, but the sample data generation takes a few seconds to complete.
|
||||
|
||||
}
|
|
@ -0,0 +1,21 @@
|
|||
meta {
|
||||
name: Create Profile
|
||||
type: http
|
||||
seq: 1
|
||||
}
|
||||
|
||||
post {
|
||||
url: {{base_url}}/profiles
|
||||
body: json
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
||||
|
||||
body:json {
|
||||
{
|
||||
"name": "{{current_profile}}"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Delete Profile
|
||||
type: http
|
||||
seq: 2
|
||||
}
|
||||
|
||||
delete {
|
||||
url: {{base_url}}/profiles/:name
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
name: {{profile}}
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
meta {
|
||||
name: Get Profiles
|
||||
type: http
|
||||
seq: 3
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/profiles
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,19 @@
|
|||
meta {
|
||||
name: Get Properties
|
||||
type: http
|
||||
seq: 4
|
||||
}
|
||||
|
||||
get {
|
||||
url: {{base_url}}/profiles/:name/properties
|
||||
body: none
|
||||
auth: bearer
|
||||
}
|
||||
|
||||
params:path {
|
||||
name: {{profile}}
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"version": "1",
|
||||
"name": "Finnow",
|
||||
"type": "collection",
|
||||
"ignore": [
|
||||
"node_modules",
|
||||
".git"
|
||||
]
|
||||
}
|
|
@ -0,0 +1,59 @@
|
|||
auth {
|
||||
mode: bearer
|
||||
}
|
||||
|
||||
auth:bearer {
|
||||
token: {{access_token}}
|
||||
}
|
||||
|
||||
script:pre-request {
|
||||
const axios = require("axios");
|
||||
const baseUrl = bru.getEnvVar("base_url");
|
||||
|
||||
|
||||
// Before each request, check and refresh access_token if needed.
|
||||
if (req.getAuthMode() === "bearer") {
|
||||
await checkAuth();
|
||||
}
|
||||
|
||||
async function checkAuth() {
|
||||
const access_token = bru.getEnvVar("access_token");
|
||||
if (!access_token || access_token === "null") {
|
||||
console.info("No access token is present, refreshing...");
|
||||
await refreshAuth();
|
||||
return;
|
||||
}
|
||||
// Access token exists, check that it's still valid.
|
||||
try {
|
||||
const resp = await axios.get(baseUrl + "/me", {
|
||||
headers: {"Authorization": "Bearer " + access_token}
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
return;
|
||||
} else if (resp.status === 401) {
|
||||
await refreshAuth();
|
||||
} else {
|
||||
throw resp;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAuth() {
|
||||
const payload = {
|
||||
username: bru.getEnvVar("username"),
|
||||
password: bru.getEnvVar("password")
|
||||
};
|
||||
const resp = await axios.post(baseUrl + "/login", payload, {
|
||||
headers: {"Content-Type": "application/json"}
|
||||
});
|
||||
if (resp.status === 200) {
|
||||
bru.setEnvVar("access_token", resp.data.token);
|
||||
console.info("Refreshed access token.");
|
||||
} else {
|
||||
throw resp;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,9 @@
|
|||
vars {
|
||||
username: testuser0
|
||||
password: testpass
|
||||
profile: test-profile-0
|
||||
base_url: http://localhost:8080/api
|
||||
}
|
||||
vars:secret [
|
||||
access_token
|
||||
]
|
|
@ -5,6 +5,7 @@
|
|||
"botan": "1.13.6",
|
||||
"botan-math": "1.0.4",
|
||||
"d2sqlite3": "1.0.0",
|
||||
"handy-httpd": {"path":"../../../github-andrewlalis/handy-httpd"},
|
||||
"httparsed": "1.2.1",
|
||||
"jwt": "0.4.0",
|
||||
"memutils": "1.0.10",
|
||||
|
|
|
@ -49,6 +49,7 @@ CREATE TABLE transaction_category (
|
|||
id INTEGER PRIMARY KEY,
|
||||
parent_id INTEGER,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT,
|
||||
color TEXT NOT NULL DEFAULT 'FFFFFF',
|
||||
CONSTRAINT fk_transaction_category_parent
|
||||
FOREIGN KEY (parent_id) REFERENCES transaction_category(id)
|
||||
|
|
|
@ -1,12 +1,17 @@
|
|||
/**
|
||||
* This module defines the API endpoints for dealing with Accounts directly,
|
||||
* including any data-transfer objects that are needed.
|
||||
*/
|
||||
module account.api;
|
||||
|
||||
import handy_httpd;
|
||||
|
||||
import profile.service;
|
||||
import account.model;
|
||||
import money.currency;
|
||||
import util.money;
|
||||
import util.json;
|
||||
|
||||
/// The data the API provides for an Account entity.
|
||||
struct AccountResponse {
|
||||
ulong id;
|
||||
string createdAt;
|
||||
|
@ -33,9 +38,10 @@ struct AccountResponse {
|
|||
|
||||
void handleGetAccounts(ref HttpRequestContext ctx) {
|
||||
import std.algorithm;
|
||||
import std.array;
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
auto accounts = ds.getAccountRepository().findAll()
|
||||
.map!(a => AccountResponse.of(a));
|
||||
.map!(a => AccountResponse.of(a)).array;
|
||||
writeJsonBody(ctx, accounts);
|
||||
}
|
||||
|
||||
|
@ -47,6 +53,7 @@ void handleGetAccount(ref HttpRequestContext ctx) {
|
|||
writeJsonBody(ctx, AccountResponse.of(account));
|
||||
}
|
||||
|
||||
// The data provided by a user to create a new account.
|
||||
struct AccountCreationPayload {
|
||||
string type;
|
||||
string numberSuffix;
|
||||
|
@ -58,6 +65,7 @@ struct AccountCreationPayload {
|
|||
void handleCreateAccount(ref HttpRequestContext ctx) {
|
||||
auto ds = getProfileDataSource(ctx);
|
||||
AccountCreationPayload payload = readJsonPayload!AccountCreationPayload(ctx);
|
||||
// TODO: Validate the account creation payload.
|
||||
AccountType type = AccountType.fromId(payload.type);
|
||||
Currency currency = Currency.ofCode(payload.currency);
|
||||
Account account = ds.getAccountRepository().insert(
|
||||
|
|
|
@ -3,7 +3,7 @@ module account.data;
|
|||
import handy_httpd.components.optional;
|
||||
|
||||
import account.model;
|
||||
import money.currency;
|
||||
import util.money;
|
||||
import history.model;
|
||||
|
||||
interface AccountRepository {
|
||||
|
|
|
@ -7,9 +7,9 @@ import handy_httpd.components.optional;
|
|||
|
||||
import account.data;
|
||||
import account.model;
|
||||
import money.currency;
|
||||
import history.model;
|
||||
import util.sqlite;
|
||||
import util.money;
|
||||
|
||||
class SqliteAccountRepository : AccountRepository {
|
||||
private Database db;
|
||||
|
@ -18,27 +18,22 @@ class SqliteAccountRepository : AccountRepository {
|
|||
}
|
||||
|
||||
Optional!Account findById(ulong id) {
|
||||
return findOne(
|
||||
db,
|
||||
"SELECT * FROM account WHERE id = ?",
|
||||
&parseAccount,
|
||||
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
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"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();
|
||||
VALUES (?, ?, ?, ?, ?, ?)",
|
||||
Clock.currTime(UTC()).toISOExtString(),
|
||||
type.id,
|
||||
numberSuffix,
|
||||
name,
|
||||
currency.code,
|
||||
description
|
||||
);
|
||||
ulong accountId = db.lastInsertRowid();
|
||||
return findById(accountId).orElseThrow("Couldn't find account!");
|
||||
}
|
||||
|
@ -111,9 +106,7 @@ SQL",
|
|||
);
|
||||
if (!optionalProps.isNull) return optionalProps.value;
|
||||
// No properties exist, so set them and return the new data.
|
||||
AccountCreditCardProperties props;
|
||||
props.account_id = account.id;
|
||||
props.creditLimit = -1;
|
||||
const props = AccountCreditCardProperties(account.id, -1);
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO account_credit_card_properties (account_id, credit_limit) VALUES (?, ?)",
|
||||
|
|
|
@ -1,18 +1,18 @@
|
|||
module account.model;
|
||||
|
||||
import std.datetime;
|
||||
import std.traits : EnumMembers;
|
||||
|
||||
import money.currency;
|
||||
import util.money;
|
||||
|
||||
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;
|
||||
static foreach (t; ALL_ACCOUNT_TYPES) {
|
||||
if (t.id == id) return t;
|
||||
}
|
||||
throw new Exception("Invalid account type id " ~ id);
|
||||
}
|
||||
|
@ -25,18 +25,20 @@ enum AccountTypes : AccountType {
|
|||
BROKERAGE = AccountType("BROKERAGE", "Brokerage", true)
|
||||
}
|
||||
|
||||
immutable(AccountType[]) ALL_ACCOUNT_TYPES = cast(AccountType[]) [ EnumMembers!AccountTypes ];
|
||||
|
||||
struct Account {
|
||||
ulong id;
|
||||
SysTime createdAt;
|
||||
bool archived;
|
||||
AccountType type;
|
||||
string numberSuffix;
|
||||
string name;
|
||||
Currency currency;
|
||||
string description;
|
||||
const ulong id;
|
||||
const SysTime createdAt;
|
||||
const bool archived;
|
||||
const AccountType type;
|
||||
const string numberSuffix;
|
||||
const string name;
|
||||
const Currency currency;
|
||||
const string description;
|
||||
}
|
||||
|
||||
struct AccountCreditCardProperties {
|
||||
ulong account_id;
|
||||
long creditLimit;
|
||||
const ulong account_id;
|
||||
const long creditLimit;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
module account;
|
||||
|
||||
public import account.api;
|
||||
public import account.data;
|
||||
public import account.data_impl_sqlite;
|
||||
public import account.model;
|
|
@ -17,6 +17,9 @@ PathHandler mapApiHandlers() {
|
|||
h.addMapping(Method.GET, API_PATH ~ "/status", &getStatus);
|
||||
h.addMapping(Method.OPTIONS, API_PATH ~ "/**", &getOptions);
|
||||
|
||||
// Dev endpoint for sample data: REMOVE BEFORE DEPLOYING!!!
|
||||
h.addMapping(Method.POST, API_PATH ~ "/sample-data", &sampleDataEndpoint);
|
||||
|
||||
// Auth endpoints:
|
||||
import auth.api;
|
||||
h.addMapping(Method.POST, API_PATH ~ "/login", &postLogin);
|
||||
|
@ -26,6 +29,7 @@ PathHandler mapApiHandlers() {
|
|||
// Authenticated endpoints:
|
||||
PathHandler a = new PathHandler();
|
||||
a.addMapping(Method.GET, API_PATH ~ "/me", &getMyUser);
|
||||
a.addMapping(Method.DELETE, API_PATH ~ "/me", &deleteMyUser);
|
||||
|
||||
import profile.api;
|
||||
a.addMapping(Method.GET, API_PATH ~ "/profiles", &handleGetProfiles);
|
||||
|
@ -58,3 +62,10 @@ private void getStatus(ref HttpRequestContext ctx) {
|
|||
|
||||
private void getOptions(ref HttpRequestContext ctx) {
|
||||
}
|
||||
|
||||
private void sampleDataEndpoint(ref HttpRequestContext ctx) {
|
||||
import util.sample_data;
|
||||
import core.thread;
|
||||
Thread t = new Thread(() => generateSampleData());
|
||||
t.start();
|
||||
}
|
||||
|
|
|
@ -11,10 +11,22 @@ import auth.service;
|
|||
import auth.data_impl_fs;
|
||||
import util.json;
|
||||
|
||||
/// 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 {
|
||||
const string token;
|
||||
}
|
||||
|
||||
void postLogin(ref HttpRequestContext ctx) {
|
||||
LoginCredentials loginCredentials = readJsonPayload!LoginCredentials(ctx);
|
||||
if (!validateUsername(loginCredentials.username)) {
|
||||
ctx.response.status = HttpStatus.UNAUTHORIZED;
|
||||
ctx.response.writeBodyString("Username is not valid.");
|
||||
return;
|
||||
}
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
|
@ -33,6 +45,10 @@ void postLogin(ref HttpRequestContext ctx) {
|
|||
writeJsonBody(ctx, TokenResponse(token));
|
||||
}
|
||||
|
||||
struct UsernameAvailabilityResponse {
|
||||
const bool available;
|
||||
}
|
||||
|
||||
void getUsernameAvailability(ref HttpRequestContext ctx) {
|
||||
Optional!string username = ctx.request.queryParams.getFirst("username");
|
||||
if (username.isNull) {
|
||||
|
@ -45,6 +61,11 @@ void getUsernameAvailability(ref HttpRequestContext ctx) {
|
|||
writeJsonBody(ctx, UsernameAvailabilityResponse(available));
|
||||
}
|
||||
|
||||
struct RegistrationData {
|
||||
string username;
|
||||
string password;
|
||||
}
|
||||
|
||||
void postRegister(ref HttpRequestContext ctx) {
|
||||
RegistrationData registrationData = readJsonPayload!RegistrationData(ctx);
|
||||
if (!validateUsername(registrationData.username)) {
|
||||
|
@ -63,13 +84,8 @@ void postRegister(ref HttpRequestContext ctx) {
|
|||
ctx.response.writeBodyString("Username is taken.");
|
||||
return;
|
||||
}
|
||||
|
||||
import botan.passhash.bcrypt : generateBcrypt;
|
||||
import botan.rng.auto_rng;
|
||||
|
||||
RandomNumberGenerator rng = new AutoSeededRNG();
|
||||
string passwordHash = generateBcrypt(registrationData.password, rng, 12);
|
||||
User user = userRepo.createUser(registrationData.username, passwordHash);
|
||||
|
||||
User user = createNewUser(userRepo, registrationData.username, registrationData.password);
|
||||
infoF!"Created user: %s"(registrationData.username);
|
||||
string token = generateAccessToken(user);
|
||||
writeJsonBody(ctx, TokenResponse(token));
|
||||
|
@ -79,3 +95,10 @@ void getMyUser(ref HttpRequestContext ctx) {
|
|||
AuthContext auth = getAuthContext(ctx);
|
||||
ctx.response.writeBodyString(auth.user.username);
|
||||
}
|
||||
|
||||
void deleteMyUser(ref HttpRequestContext ctx) {
|
||||
AuthContext auth = getAuthContext(ctx);
|
||||
UserRepository userRepo = new FileSystemUserRepository();
|
||||
deleteUser(auth.user, userRepo);
|
||||
infoF!"Deleted user: %s"(auth.user.username);
|
||||
}
|
||||
|
|
|
@ -3,28 +3,9 @@ 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 UsernameAvailabilityResponse {
|
||||
bool available;
|
||||
}
|
||||
|
||||
struct RegistrationData {
|
||||
string username;
|
||||
string password;
|
||||
}
|
||||
|
||||
interface UserRepository {
|
||||
Optional!User findByUsername(string username);
|
||||
User[] findAll();
|
||||
User createUser(string username, string passwordHash);
|
||||
void deleteByUsername(string username);
|
||||
}
|
||||
|
|
|
@ -27,11 +27,16 @@ class FileSystemUserRepository : UserRepository {
|
|||
) {
|
||||
return Optional!User.empty;
|
||||
}
|
||||
JSONValue userObj = parseJSON(readText(getUserDataFile(username)));
|
||||
return Optional!User.of(User(
|
||||
username,
|
||||
userObj.object["passwordHash"].str
|
||||
));
|
||||
return Optional!User.of(readUser(username));
|
||||
}
|
||||
|
||||
User[] findAll() {
|
||||
User[] users;
|
||||
foreach (DirEntry entry; dirEntries(this.usersDir, SpanMode.shallow, false)) {
|
||||
string username = baseName(entry.name);
|
||||
users ~= readUser(username);
|
||||
}
|
||||
return users;
|
||||
}
|
||||
|
||||
User createUser(string username, string passwordHash) {
|
||||
|
@ -59,4 +64,15 @@ class FileSystemUserRepository : UserRepository {
|
|||
private string getUserDataFile(string username) {
|
||||
return buildPath(this.usersDir, username, "user-data.json");
|
||||
}
|
||||
|
||||
private User readUser(string username) {
|
||||
if (!exists(getUserDataFile(username))) {
|
||||
throw new Exception("User data file for " ~ username ~ " doesn't exist.");
|
||||
}
|
||||
JSONValue userObj = parseJSON(readText(getUserDataFile(username)));
|
||||
return User(
|
||||
username,
|
||||
userObj.object["passwordHash"].str
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
module auth;
|
||||
|
||||
public import auth.api;
|
||||
public import auth.data;
|
||||
public import auth.data_impl_fs;
|
||||
public import auth.model;
|
||||
public import auth.service;
|
|
@ -11,6 +11,18 @@ import auth.data_impl_fs;
|
|||
|
||||
const SECRET = "temporary-insecure-secret"; // TODO: Load secret from application config!
|
||||
|
||||
User createNewUser(UserRepository repo, string username, string password) {
|
||||
import botan.passhash.bcrypt : generateBcrypt;
|
||||
import botan.rng.auto_rng;
|
||||
RandomNumberGenerator rng = new AutoSeededRNG();
|
||||
string passwordHash = generateBcrypt(password, rng, 12);
|
||||
return repo.createUser(username, passwordHash);
|
||||
}
|
||||
|
||||
void deleteUser(User user, UserRepository repo) {
|
||||
repo.deleteByUsername(user.username);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a new JWT access token for a user.
|
||||
* Params:
|
||||
|
|
|
@ -10,5 +10,6 @@ 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);
|
||||
}
|
|
@ -7,6 +7,7 @@ import d2sqlite3;
|
|||
|
||||
import history.data;
|
||||
import history.model;
|
||||
import util.sqlite;
|
||||
|
||||
class SqliteHistoryRepository : HistoryRepository {
|
||||
private Database db;
|
||||
|
@ -47,6 +48,15 @@ SQL";
|
|||
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);
|
||||
|
@ -72,4 +82,13 @@ SQL";
|
|||
item.content = row.peek!string(1);
|
||||
return item;
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
|
@ -1,33 +0,0 @@
|
|||
module money.currency;
|
||||
|
||||
struct Currency {
|
||||
const char[3] code;
|
||||
const ubyte fractionalDigits;
|
||||
const ushort numericCode;
|
||||
|
||||
import std.traits : isSomeString, EnumMembers;
|
||||
static Currency ofCode(S)(S code) if (isSomeString!S) {
|
||||
if (code.length != 3) {
|
||||
throw new Exception("Invalid currency code: " ~ code);
|
||||
}
|
||||
static foreach (c; EnumMembers!Currencies) {
|
||||
if (c.code == code) return c;
|
||||
}
|
||||
throw new Exception("Unknown currency code: " ~ code);
|
||||
}
|
||||
}
|
||||
|
||||
enum Currencies : Currency {
|
||||
USD = Currency("USD", 2, 840),
|
||||
CAD = Currency("CAD", 2, 124),
|
||||
GBP = Currency("GBP", 2, 826),
|
||||
EUR = Currency("EUR", 2, 978),
|
||||
CHF = Currency("CHF", 2, 756),
|
||||
ZAR = Currency("ZAR", 2, 710),
|
||||
JPY = Currency("JPY", 0, 392),
|
||||
INR = Currency("INR", 2, 356)
|
||||
}
|
||||
|
||||
unittest {
|
||||
assert(Currency.ofCode("USD") == Currencies.USD);
|
||||
}
|
|
@ -3,6 +3,7 @@ module profile.data;
|
|||
import handy_httpd.components.optional;
|
||||
import profile.model;
|
||||
|
||||
/// Repository for interacting with the set of profiles belonging to a user.
|
||||
interface ProfileRepository {
|
||||
Optional!Profile findByName(string name);
|
||||
Profile createProfile(string name);
|
||||
|
|
|
@ -12,6 +12,7 @@ import util.sqlite;
|
|||
|
||||
const DEFAULT_USERS_DIR = "users";
|
||||
|
||||
/// Profile repository that uses an SQLite3 database file for each profile.
|
||||
class FileSystemProfileRepository : ProfileRepository {
|
||||
import std.path;
|
||||
import std.file;
|
||||
|
@ -97,11 +98,6 @@ class SqlitePropertiesRepository : PropertiesRepository {
|
|||
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) {
|
||||
|
|
|
@ -4,3 +4,9 @@
|
|||
* completely separate from all others.
|
||||
*/
|
||||
module profile;
|
||||
|
||||
public import profile.api;
|
||||
public import profile.data;
|
||||
public import profile.data_impl_sqlite;
|
||||
public import profile.model;
|
||||
public import profile.service;
|
||||
|
|
|
@ -18,7 +18,7 @@ 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_]+$`);
|
||||
auto r = ctRegex!(`^[a-zA-Z]+[a-zA-Z0-9_-]+$`);
|
||||
return !matchFirst(name, r).empty;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,46 @@
|
|||
module transaction.data;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import std.datetime;
|
||||
|
||||
import transaction.model;
|
||||
import util.money;
|
||||
|
||||
interface TransactionVendorRepository {
|
||||
Optional!TransactionVendor findById(ulong id);
|
||||
TransactionVendor[] findAll();
|
||||
bool existsByName(string name);
|
||||
TransactionVendor insert(string name, string description);
|
||||
void deleteById(ulong id);
|
||||
TransactionVendor updateById(ulong id, string name, string description);
|
||||
}
|
||||
|
||||
interface TransactionCategoryRepository {
|
||||
Optional!TransactionCategory findById(ulong id);
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId);
|
||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color);
|
||||
void deleteById(ulong id);
|
||||
TransactionCategory updateById(ulong id, string name, string description, string color);
|
||||
}
|
||||
|
||||
interface TransactionTagRepository {
|
||||
Optional!TransactionTag findById(ulong id);
|
||||
Optional!TransactionTag findByName(string name);
|
||||
TransactionTag[] findAll();
|
||||
TransactionTag insert(string name);
|
||||
void deleteById(ulong id);
|
||||
}
|
||||
|
||||
interface TransactionRepository {
|
||||
Optional!Transaction findById(ulong id);
|
||||
Transaction insert(
|
||||
SysTime timestamp,
|
||||
SysTime addedAt,
|
||||
ulong amount,
|
||||
Currency currency,
|
||||
string description,
|
||||
Optional!ulong vendorId,
|
||||
Optional!ulong categoryId
|
||||
);
|
||||
void deleteById(ulong id);
|
||||
}
|
|
@ -0,0 +1,181 @@
|
|||
module transaction.data_impl_sqlite;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import std.datetime;
|
||||
import d2sqlite3;
|
||||
|
||||
import transaction.model;
|
||||
import transaction.data;
|
||||
import util.sqlite;
|
||||
import util.money;
|
||||
|
||||
class SqliteTransactionVendorRepository : TransactionVendorRepository {
|
||||
private Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
Optional!TransactionVendor findById(ulong id) {
|
||||
return util.sqlite.findById(db, "transaction_vendor", &parseVendor, id);
|
||||
}
|
||||
|
||||
TransactionVendor[] findAll() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_vendor ORDER BY name ASC",
|
||||
&parseVendor
|
||||
);
|
||||
}
|
||||
|
||||
bool existsByName(string name) {
|
||||
return util.sqlite.exists(db, "SELECT id FROM transaction_vendor WHERE name = ?", name);
|
||||
}
|
||||
|
||||
TransactionVendor insert(string name, string description) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_vendor (name, description) VALUES (?, ?)",
|
||||
name, description
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.deleteById(db, "transaction_vendor", id);
|
||||
}
|
||||
|
||||
TransactionVendor updateById(ulong id, string name, string description) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"UPDATE transaction_vendor SET name = ?, description = ? WHERE id = ?",
|
||||
name, description, id
|
||||
);
|
||||
return findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
private static TransactionVendor parseVendor(Row row) {
|
||||
return TransactionVendor(
|
||||
row.peek!ulong(0),
|
||||
row.peek!string(1),
|
||||
row.peek!string(2)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteTransactionCategoryRepository : TransactionCategoryRepository {
|
||||
private Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
Optional!TransactionCategory findById(ulong id) {
|
||||
return util.sqlite.findById(db, "transaction_category", &parseCategory, id);
|
||||
}
|
||||
|
||||
TransactionCategory[] findAllByParentId(Optional!ulong parentId) {
|
||||
if (parentId) {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_category WHERE parent_id = ? ORDER BY name ASC",
|
||||
&parseCategory,
|
||||
parentId.value
|
||||
);
|
||||
}
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_category WHERE parent_id IS NULL ORDER BY name ASC",
|
||||
&parseCategory
|
||||
);
|
||||
}
|
||||
|
||||
TransactionCategory insert(Optional!ulong parentId, string name, string description, string color) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_category
|
||||
(parent_id, name, description, color)
|
||||
VALUES (?, ?, ?, ?)",
|
||||
parentId.asNullable(), name, description, color
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.deleteById(db, "transaction_category", id);
|
||||
}
|
||||
|
||||
TransactionCategory updateById(ulong id, string name, string description, string color) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"UPDATE transaction_category
|
||||
SET name = ?, description = ?, color = ?
|
||||
WHERE id = ?",
|
||||
name, description, color, id
|
||||
);
|
||||
return findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
private static TransactionCategory parseCategory(Row row) {
|
||||
import std.typecons;
|
||||
return TransactionCategory(
|
||||
row.peek!ulong(0),
|
||||
Optional!ulong.of(row.peek!(Nullable!ulong)(1)),
|
||||
row.peek!string(2),
|
||||
row.peek!string(3),
|
||||
row.peek!string(4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class SqliteTransactionTagRepository : TransactionTagRepository {
|
||||
private Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
Optional!TransactionTag findById(ulong id) {
|
||||
return findOne(db, "SELECT * FROM transaction_tag WHERE id = ?", &parseTag, id);
|
||||
}
|
||||
|
||||
Optional!TransactionTag findByName(string name) {
|
||||
return findOne(db, "SELECT * FROM transaction_tag WHERE name = ?", &parseTag, name);
|
||||
}
|
||||
|
||||
TransactionTag[] findAll() {
|
||||
return util.sqlite.findAll(
|
||||
db,
|
||||
"SELECT * FROM transaction_tag ORDER BY name ASC",
|
||||
&parseTag
|
||||
);
|
||||
}
|
||||
|
||||
TransactionTag insert(string name) {
|
||||
auto existingTag = findByName(name);
|
||||
if (existingTag) {
|
||||
return existingTag.value;
|
||||
}
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"INSERT INTO transaction_tag (name) VALUES (?)",
|
||||
name
|
||||
);
|
||||
ulong id = db.lastInsertRowid();
|
||||
return findById(id).orElseThrow();
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.update(
|
||||
db,
|
||||
"DELETE FROM transaction_tag WHERE id = ?",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
private static TransactionTag parseTag(Row row) {
|
||||
return TransactionTag(
|
||||
row.peek!ulong(0),
|
||||
row.peek!string(1)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,34 +1,48 @@
|
|||
module transaction.model;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import std.datetime;
|
||||
|
||||
import money.currency;
|
||||
import util.money;
|
||||
|
||||
struct TransactionVendor {
|
||||
ulong id;
|
||||
string name;
|
||||
string description;
|
||||
const ulong id;
|
||||
const string name;
|
||||
const string description;
|
||||
}
|
||||
|
||||
struct TransactionCategory {
|
||||
ulong id;
|
||||
ulong parentId;
|
||||
string name;
|
||||
string color;
|
||||
const ulong id;
|
||||
const Optional!ulong parentId;
|
||||
const string name;
|
||||
const string description;
|
||||
const string color;
|
||||
}
|
||||
|
||||
struct TransactionTag {
|
||||
ulong id;
|
||||
string name;
|
||||
const ulong id;
|
||||
const string name;
|
||||
}
|
||||
|
||||
struct Transaction {
|
||||
ulong id;
|
||||
SysTime timestamp;
|
||||
SysTime addedAt;
|
||||
ulong amount;
|
||||
Currency currency;
|
||||
string description;
|
||||
ulong vendorId;
|
||||
ulong categoryId;
|
||||
}
|
||||
const ulong id;
|
||||
/// The time at which the transaction happened.
|
||||
const SysTime timestamp;
|
||||
/// The time at which the transaction entity was saved.
|
||||
const SysTime addedAt;
|
||||
const ulong amount;
|
||||
const Currency currency;
|
||||
const string description;
|
||||
const Optional!ulong vendorId;
|
||||
const Optional!ulong categoryId;
|
||||
}
|
||||
|
||||
struct TransactionLineItem {
|
||||
const ulong id;
|
||||
const ulong transactionId;
|
||||
const long valuePerItem;
|
||||
const ulong quantity;
|
||||
const uint idx;
|
||||
const string description;
|
||||
const Optional!ulong categoryId;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,4 @@
|
|||
module transaction;
|
||||
|
||||
public import transaction.data;
|
||||
public import transaction.model;
|
|
@ -8,7 +8,7 @@ import asdf;
|
|||
/**
|
||||
* Reads a JSON payload into a type T. Throws an `HttpStatusException` if
|
||||
* the data cannot be read or converted to the given type, with a 400 BAD
|
||||
* REQUEST status.
|
||||
* REQUEST status. The type T should not have `const` members.
|
||||
* Params:
|
||||
* ctx = The request context to read from.
|
||||
* Returns: The data that was read.
|
||||
|
|
|
@ -0,0 +1,83 @@
|
|||
module util.money;
|
||||
|
||||
import std.traits : isSomeString, EnumMembers;
|
||||
|
||||
/**
|
||||
* Basic information about a monetary currency, as defined by ISO 4217.
|
||||
* https://en.wikipedia.org/wiki/ISO_4217
|
||||
*/
|
||||
struct Currency {
|
||||
/// The common 3-character code for the currency, like "USD".
|
||||
const char[3] code;
|
||||
/// The number of digits after the decimal place that the currency supports.
|
||||
const ubyte fractionalDigits;
|
||||
/// The ISO 4217 numeric code for the currency.
|
||||
const ushort numericCode;
|
||||
|
||||
static Currency ofCode(S)(S code) if (isSomeString!S) {
|
||||
if (code.length != 3) {
|
||||
throw new Exception("Invalid currency code: " ~ code);
|
||||
}
|
||||
static foreach (c; ALL_CURRENCIES) {
|
||||
if (c.code == code) return c;
|
||||
}
|
||||
throw new Exception("Unknown currency code: " ~ code);
|
||||
}
|
||||
}
|
||||
|
||||
/// An enumeration of all available currencies.
|
||||
enum Currencies : Currency {
|
||||
AUD = Currency("AUD", 2, 36),
|
||||
USD = Currency("USD", 2, 840),
|
||||
CAD = Currency("CAD", 2, 124),
|
||||
GBP = Currency("GBP", 2, 826),
|
||||
EUR = Currency("EUR", 2, 978),
|
||||
CHF = Currency("CHF", 2, 756),
|
||||
ZAR = Currency("ZAR", 2, 710),
|
||||
JPY = Currency("JPY", 0, 392),
|
||||
INR = Currency("INR", 2, 356)
|
||||
}
|
||||
|
||||
immutable(Currency[]) ALL_CURRENCIES = cast(Currency[]) [EnumMembers!Currencies];
|
||||
|
||||
unittest {
|
||||
assert(Currency.ofCode("USD") == Currencies.USD);
|
||||
}
|
||||
|
||||
/**
|
||||
* A monetary value consisting of an integer value, and a currency. The value
|
||||
* is interpreted as a multiple of the smallest denomination of the currency,
|
||||
* so for example, with USD currency, a value of 123 indicates $1.23.
|
||||
*/
|
||||
struct MoneyValue {
|
||||
const Currency currency;
|
||||
const long value;
|
||||
|
||||
int opCmp(in MoneyValue other) const {
|
||||
if (other.currency != this.currency) return 0;
|
||||
if (this.value < other.value) return -1;
|
||||
if (this.value > other.value) return 1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
MoneyValue opBinary(string op)(in MoneyValue rhs) const {
|
||||
if (rhs.currency != this.currency)
|
||||
throw new Exception("Cannot perform binary operations on MoneyValues with different currencies.");
|
||||
static if (op == "+") return MoneyValue(currency, this.value + rhs.value);
|
||||
static if (op == "-") return MoneyValue(currency, this.value - rhs.value);
|
||||
static assert(false, "Operator " ~ op ~ " is not supported.");
|
||||
}
|
||||
|
||||
MoneyValue opBinary(string op)(int rhs) const {
|
||||
static if (op == "+") return MoneyValue(currency, this.value + rhs);
|
||||
static if (op == "-") return MoneyValue(currency, this.value - rhs);
|
||||
static if (op == "*") return MoneyValue(currency, this.value * rhs);
|
||||
static if (op == "/") return MoneyValue(currency, this.value / rhs);
|
||||
static assert(false, "Operator " ~ op ~ " is not supported.");
|
||||
}
|
||||
|
||||
MoneyValue opUnary(string op)() const {
|
||||
static if (op == "-") return MoneyValue(currency, -this.value);
|
||||
static assert(false, "Operator " ~ op ~ " is not supported.");
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
module util.repository;
|
||||
|
||||
import handy_httpd.components.optional;
|
||||
import d2sqlite3;
|
||||
import util.sqlite;
|
||||
|
||||
class CrudRepository(T, string table)
|
||||
if (__traits(hasMember, T, "id")) {
|
||||
|
||||
protected Database db;
|
||||
this(Database db) {
|
||||
this.db = db;
|
||||
}
|
||||
|
||||
abstract T parseResult(Row r);
|
||||
|
||||
Optional!T findById(ulong id) {
|
||||
return util.sqlite.findById(db, table, &parseResult, id);
|
||||
}
|
||||
|
||||
bool existsById(ulong id) {
|
||||
return util.sqlite.exists(
|
||||
db,
|
||||
"SELECT id FROM " ~ table ~ " WHERE id = ?",
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
void deleteById(ulong id) {
|
||||
util.sqlite.deleteById(db, table, id);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,69 @@
|
|||
module util.sample_data;
|
||||
|
||||
import slf4d;
|
||||
|
||||
import auth;
|
||||
import profile;
|
||||
import account;
|
||||
import util.money;
|
||||
|
||||
import std.random;
|
||||
import std.conv;
|
||||
import std.array;
|
||||
|
||||
void generateSampleData() {
|
||||
UserRepository userRepo = new FileSystemUserRepository;
|
||||
// Remove all existing user data.
|
||||
foreach (User user; userRepo.findAll()) {
|
||||
userRepo.deleteByUsername(user.username);
|
||||
}
|
||||
|
||||
const int userCount = uniform(5, 10);
|
||||
for (int i = 0; i < userCount; i++) {
|
||||
generateRandomUser(i, userRepo);
|
||||
}
|
||||
info("Random sample data generation complete.");
|
||||
}
|
||||
|
||||
void generateRandomUser(int idx, UserRepository userRepo) {
|
||||
string username = "testuser" ~ idx.to!string;
|
||||
string password = "testpass";
|
||||
infoF!"Generating random user %s, password: %s."(username, password);
|
||||
User user = createNewUser(userRepo, username, password);
|
||||
ProfileRepository profileRepo = new FileSystemProfileRepository(username);
|
||||
const int profileCount = uniform(1, 5);
|
||||
for (int i = 0; i < profileCount; i++) {
|
||||
generateRandomProfile(i, profileRepo);
|
||||
}
|
||||
}
|
||||
|
||||
void generateRandomProfile(int idx, ProfileRepository profileRepo) {
|
||||
string profileName = "test-profile-" ~ idx.to!string;
|
||||
infoF!" Generating random profile %s."(profileName);
|
||||
Profile profile = profileRepo.createProfile(profileName);
|
||||
ProfileDataSource ds = profileRepo.getDataSource(profile);
|
||||
ds.getPropertiesRepository().setProperty("sample-data-idx", idx.to!string);
|
||||
|
||||
const int accountCount = uniform(1, 10);
|
||||
for (int i = 0; i < accountCount; i++) {
|
||||
generateRandomAccount(i, ds);
|
||||
}
|
||||
}
|
||||
|
||||
void generateRandomAccount(int idx, ProfileDataSource ds) {
|
||||
AccountRepository accountRepo = ds.getAccountRepository();
|
||||
string idxStr = idx.to!string;
|
||||
string numberSuffix = "0".replicate(4 - idxStr.length) ~ idxStr;
|
||||
string name = "Test Account " ~ idxStr;
|
||||
AccountType type = choice(ALL_ACCOUNT_TYPES);
|
||||
Currency currency = choice(ALL_CURRENCIES);
|
||||
string description = "This is a testing account generated by util.sample_data.generateRandomAccount().";
|
||||
infoF!" Generating random account: %s, ...%s"(name, numberSuffix);
|
||||
Account account = accountRepo.insert(
|
||||
type,
|
||||
numberSuffix,
|
||||
name,
|
||||
currency,
|
||||
description
|
||||
);
|
||||
}
|
|
@ -21,6 +21,23 @@ Optional!T findOne(T, Args...)(Database db, string query, T function(Row) result
|
|||
return Optional!T.of(resultMapper(result.front));
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to find a single entity by its id, selecting all properties.
|
||||
* Params:
|
||||
* db = The database to use.
|
||||
* table = The table to select from.
|
||||
* resultMapper = A function to map rows to the desired result type.
|
||||
* id = The entity's id.
|
||||
* Returns: An optional result.
|
||||
*/
|
||||
Optional!T findById(T)(Database db, string table, T function(Row) resultMapper, ulong id) {
|
||||
Statement stmt = db.prepare("SELECT * FROM " ~ table);
|
||||
stmt.bind(1, id);
|
||||
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:
|
||||
|
@ -67,6 +84,19 @@ int update(Args...)(Database db, string query, Args args) {
|
|||
return db.changes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes an entity from a table.
|
||||
* Params:
|
||||
* db = The database to use.
|
||||
* table = The table to delete from.
|
||||
* id = The id of the entity to delete.
|
||||
*/
|
||||
void deleteById(Database db, string table, ulong id) {
|
||||
Statement stmt = db.prepare("DELETE FROM " ~ table ~ " WHERE id = ?");
|
||||
stmt.bind(1, id);
|
||||
stmt.execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
|
|
@ -76,3 +76,15 @@ Future<String> postRegister(LoginCredentials credentials) async {
|
|||
throw Exception('Registration failed.');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteUser(String token) async {
|
||||
final http.Response response = await http.delete(
|
||||
Uri.parse('http://localhost:8080/api/me'),
|
||||
headers: {
|
||||
'Authorization': 'Bearer $token'
|
||||
}
|
||||
);
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('Deleting user failed.');
|
||||
}
|
||||
}
|
||||
|
|
|
@ -19,7 +19,7 @@ class FinnowApp extends StatelessWidget with WatchItMixin {
|
|||
if (newValue.state.authenticated()) {
|
||||
router.replace('/profiles');
|
||||
} else {
|
||||
while (router.canPop()) {
|
||||
while (router.canPop()) {// Clear the navigation stack.
|
||||
router.pop();
|
||||
}
|
||||
router.pushReplacement('/login');
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
import 'package:finnow_app/api/profile.dart';
|
||||
import 'package:finnow_app/main.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
/// A list item that shows a profile in the user's list of all profiles.
|
||||
class ProfileListItem extends StatelessWidget {
|
||||
final Profile profile;
|
||||
const ProfileListItem(this.profile, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
getIt<GoRouter>().go('/profiles/${profile.name}');
|
||||
},
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
padding: const EdgeInsets.all(10),
|
||||
margin: const EdgeInsets.only(top: 10, bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10), color: const Color.fromARGB(255, 168, 233, 170)),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(profile.name)),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
print('Removing profile: ${profile.name}');
|
||||
},
|
||||
child: const Text('Remove'))
|
||||
])));
|
||||
}
|
||||
}
|
|
@ -3,12 +3,10 @@ import 'package:finnow_app/auth/model.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get_it/get_it.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:watch_it/watch_it.dart';
|
||||
import 'app.dart';
|
||||
import 'login_page.dart';
|
||||
import 'profiles_page.dart';
|
||||
import 'register_page.dart';
|
||||
import 'router.dart';
|
||||
|
||||
/// The global GetIt instance, used to get registered singletons.
|
||||
final getIt = GetIt.instance;
|
||||
|
||||
void main() {
|
||||
|
@ -16,49 +14,15 @@ void main() {
|
|||
runApp(const FinnowApp());
|
||||
}
|
||||
|
||||
/// Initializes some resources before we start the app.
|
||||
void setup() {
|
||||
// Register the FinnowApi so it can be called easily from anywhere.
|
||||
getIt.registerSingleton<FinnowApi>(FinnowApi());
|
||||
|
||||
// Register the AuthenticationModel singleton to hold the app's current
|
||||
// authentication information.
|
||||
getIt.registerSingleton<AuthenticationModel>(AuthenticationModel());
|
||||
|
||||
// Register the GoRouter singleton to provide navigation in the app.
|
||||
getIt.registerSingleton<GoRouter>(getRouterConfig());
|
||||
}
|
||||
|
||||
GoRouter getRouterConfig() {
|
||||
return GoRouter(routes: [
|
||||
GoRoute(path: '/login', builder: (context, state) => const LoginPage()),
|
||||
GoRoute(
|
||||
path: '/register', builder: (context, state) => const RegisterPage()),
|
||||
// Once a user has logged in, they're directed to a scaffold for the /profiles page.
|
||||
ShellRoute(
|
||||
builder: (context, state, child) => Scaffold(
|
||||
body: child,
|
||||
appBar: AppBar(
|
||||
title: const Text('Finnow'),
|
||||
backgroundColor: Colors.grey,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => getIt<AuthenticationModel>().state =
|
||||
Unauthenticated(),
|
||||
child: const Text('Logout'))
|
||||
],
|
||||
),
|
||||
),
|
||||
redirect: (context, state) {
|
||||
final bool authenticated =
|
||||
getIt<AuthenticationModel>().state.authenticated();
|
||||
return authenticated ? null : '/login';
|
||||
},
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: '/',
|
||||
redirect: (context, state) {
|
||||
final bool authenticated =
|
||||
getIt<AuthenticationModel>().state.authenticated();
|
||||
return authenticated ? '/profiles' : '/login';
|
||||
}),
|
||||
GoRoute(
|
||||
path: '/profiles',
|
||||
builder: (context, state) => const ProfilesPage(),
|
||||
)
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
|
|
@ -4,8 +4,8 @@ import 'package:finnow_app/components/title_text.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'api/auth.dart';
|
||||
import 'main.dart';
|
||||
import '../api/auth.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class LoginPage extends StatelessWidget {
|
||||
const LoginPage({super.key});
|
|
@ -0,0 +1,17 @@
|
|||
import 'package:finnow_app/api/profile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class ProfilePage extends StatelessWidget {
|
||||
final Profile profile;
|
||||
const ProfilePage(this.profile, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: Text(profile.name)
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,8 +1,9 @@
|
|||
import 'package:finnow_app/auth/model.dart';
|
||||
import 'package:finnow_app/components/profile_list_item.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'api/profile.dart';
|
||||
import 'main.dart';
|
||||
import '../api/profile.dart';
|
||||
import '../main.dart';
|
||||
|
||||
class ProfilesPage extends StatelessWidget {
|
||||
const ProfilesPage({super.key});
|
||||
|
@ -11,9 +12,9 @@ class ProfilesPage extends StatelessWidget {
|
|||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: const Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
padding: EdgeInsets.all(10),
|
||||
child: Column(children: [
|
||||
Text('Profiles', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
|
||||
Text('Select a Profile', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
|
||||
SizedBox(height: 10),
|
||||
Expanded(child: _ProfilesListView()),
|
||||
])),
|
||||
|
@ -39,7 +40,7 @@ class __ProfilesListViewState extends State<_ProfilesListView> {
|
|||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
scrollDirection: Axis.vertical,
|
||||
children: profiles.map((p) => Text('Profile: ${p.name}')).toList(),
|
||||
children: profiles.map(ProfileListItem.new).toList(),
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
import 'package:finnow_app/api/auth.dart';
|
||||
import 'package:finnow_app/auth/model.dart';
|
||||
import 'package:finnow_app/components/form_label.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../main.dart';
|
||||
|
||||
class UserAccountPage extends StatelessWidget {
|
||||
final Authenticated auth;
|
||||
const UserAccountPage(this.auth, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
child: ListView(children: [
|
||||
const Text('My User',
|
||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32.0)),
|
||||
const SizedBox(height: 10),
|
||||
const FormLabel('Username'),
|
||||
Text(auth.username),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
TextButton(
|
||||
onPressed: () => attemptDeleteUser(context),
|
||||
child: const Text('Delete my user'))
|
||||
])
|
||||
]));
|
||||
}
|
||||
|
||||
void attemptDeleteUser(BuildContext ctx) async {
|
||||
bool confirmed = false;
|
||||
await showDialog(
|
||||
context: ctx,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Confirm User Deletion'),
|
||||
content: const Text(
|
||||
'Are you sure you want to delete your user? This is a permanent action that cannot be undone.'),
|
||||
actions: [
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
confirmed = true;
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Ok')),
|
||||
FilledButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
child: Text('Cancel'))
|
||||
]);
|
||||
});
|
||||
if (confirmed) {
|
||||
await deleteUser(auth.token);
|
||||
getIt<AuthenticationModel>().state = Unauthenticated();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,95 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'api/profile.dart';
|
||||
import 'auth/model.dart';
|
||||
import 'pages/login_page.dart';
|
||||
import 'main.dart';
|
||||
import 'pages/profile_page.dart';
|
||||
import 'pages/profiles_page.dart';
|
||||
import 'pages/register_page.dart';
|
||||
import 'pages/user_account_page.dart';
|
||||
|
||||
GoRouter getRouterConfig() {
|
||||
return GoRouter(routes: [
|
||||
GoRoute(path: '/login', builder: (ctx, state) => const LoginPage()),
|
||||
GoRoute(path: '/register', builder: (ctx, state) => const RegisterPage()),
|
||||
// Once a user has logged in, they're directed to a scaffold for the /profiles page.
|
||||
ShellRoute(
|
||||
builder: getAppScaffold,
|
||||
redirect: (context, state) {
|
||||
// If the user isn't authenticated when they navigate here, send them
|
||||
// back to /login.
|
||||
final bool authenticated =
|
||||
getIt<AuthenticationModel>().state.authenticated();
|
||||
return authenticated ? null : '/login';
|
||||
},
|
||||
routes: [
|
||||
// The "/" route is just a dummy landing route that redirects to
|
||||
// /profiles if the user is authenticated, or /login otherwise.
|
||||
GoRoute(
|
||||
path: '/',
|
||||
redirect: (context, state) {
|
||||
final bool authenticated =
|
||||
getIt<AuthenticationModel>().state.authenticated();
|
||||
return authenticated ? '/profiles' : '/login';
|
||||
}),
|
||||
GoRoute(
|
||||
path: '/profiles',
|
||||
builder: (context, state) => const ProfilesPage(),
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: ':profile',
|
||||
builder: (context, state) {
|
||||
final String profileName =
|
||||
state.pathParameters['profile']!;
|
||||
return ProfilePage(Profile(profileName));
|
||||
}),
|
||||
]),
|
||||
GoRoute(
|
||||
path: '/user-account',
|
||||
builder: (ctx, state) => UserAccountPage(getIt<AuthenticationModel>().state as Authenticated)
|
||||
)
|
||||
]),
|
||||
]);
|
||||
}
|
||||
|
||||
/// Gets the scaffold for the main authenticated view of the app, which is
|
||||
/// where the user is viewing their set of profiles.
|
||||
Widget getAppScaffold(BuildContext context, GoRouterState state, Widget child) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
appBar: AppBar(
|
||||
title: const Text('Finnow'),
|
||||
backgroundColor: Colors.grey,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
final router = getIt<GoRouter>();
|
||||
router.push('/user-account');
|
||||
},
|
||||
icon: const Icon(Icons.account_circle)
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () =>
|
||||
getIt<AuthenticationModel>().state = Unauthenticated(),
|
||||
icon: const Icon(Icons.logout))
|
||||
],
|
||||
),
|
||||
bottomNavigationBar:
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [
|
||||
BackButton(
|
||||
onPressed: () {
|
||||
GoRouter router = getIt<GoRouter>();
|
||||
if (router.canPop()) router.pop();
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
GoRouter router = getIt<GoRouter>();
|
||||
router.replace('/profiles');
|
||||
},
|
||||
icon: const Icon(Icons.home))
|
||||
]),
|
||||
);
|
||||
}
|
|
@ -8,7 +8,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:finnow_app/main.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {
|
||||
|
|
Loading…
Reference in New Issue