Add file selector, attachments support.
Build and Deploy Web App / build-and-deploy (push) Failing after 11s Details
Build and Deploy API / build-and-deploy (push) Successful in 1m17s Details

This commit is contained in:
andrewlalis 2025-08-29 20:12:54 -04:00
parent 2dae054950
commit 683d11a9a4
25 changed files with 554 additions and 63 deletions

View File

@ -15,12 +15,14 @@ import account.model;
import account.service;
import util.money;
import util.pagination;
import util.data;
import account.data;
import attachment.data;
import attachment.dto;
/// The data the API provides for an Account entity.
struct AccountResponse {
import asdf : serdeTransformOut;
import util.data;
ulong id;
string createdAt;
@ -28,7 +30,7 @@ struct AccountResponse {
string type;
string numberSuffix;
string name;
string currency;
Currency currency;
string description;
@serdeTransformOut!serializeOptional
Optional!long currentBalance;
@ -41,7 +43,7 @@ struct AccountResponse {
r.type = account.type.id;
r.numberSuffix = account.numberSuffix;
r.name = account.name;
r.currency = account.currency.code.dup;
r.currency = account.currency;
r.description = account.description;
r.currentBalance = currentBalance;
return r;
@ -124,15 +126,21 @@ struct AccountValueRecordResponse {
string type;
long value;
Currency currency;
AttachmentResponse[] attachments;
static AccountValueRecordResponse of(in AccountValueRecord vr) {
static AccountValueRecordResponse of(in AccountValueRecord vr, AttachmentRepository attachmentRepo) {
import std.algorithm : map;
import std.array : array;
return AccountValueRecordResponse(
vr.id,
vr.timestamp.toISOExtString(),
vr.accountId,
vr.type,
vr.value,
vr.currency
vr.currency,
attachmentRepo.findAllByValueRecordId(vr.id)
.map!(AttachmentResponse.of)
.array
);
}
}
@ -140,9 +148,10 @@ struct AccountValueRecordResponse {
void handleGetValueRecords(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
auto ds = getProfileDataSource(request);
scope attachmentRepo = ds.getAttachmentRepository();
auto page = ds.getAccountValueRecordRepository()
.findAllByAccountId(accountId, PageRequest.parse(request, VALUE_RECORD_DEFAULT_PAGE_REQUEST))
.mapTo!()(&AccountValueRecordResponse.of);
.mapTo!()((vr) => AccountValueRecordResponse.of(vr, attachmentRepo));
writeJsonBody(response, page);
}
@ -150,9 +159,10 @@ void handleGetValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse
ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
auto ds = getProfileDataSource(request);
auto attachmentRepo = ds.getAttachmentRepository();
auto record = ds.getAccountValueRecordRepository().findById(accountId, valueRecordId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
writeJsonBody(response, AccountValueRecordResponse.of(record));
writeJsonBody(response, AccountValueRecordResponse.of(record, attachmentRepo));
}
struct ValueRecordCreationPayload {
@ -166,23 +176,50 @@ void handleCreateValueRecord(ref ServerHttpRequest request, ref ServerHttpRespon
ProfileDataSource ds = getProfileDataSource(request);
Account account = ds.getAccountRepository().findById(accountId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ValueRecordCreationPayload payload = readJsonBodyAs!ValueRecordCreationPayload(request);
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
auto fullPayload = parseMultipartFilesAndBody!ValueRecordCreationPayload(request);
ValueRecordCreationPayload payload = fullPayload.payload;
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp);
AccountValueRecordType type = AccountValueRecordType.BALANCE; // TODO: Support more types.
AccountValueRecord record = ds.getAccountValueRecordRepository().insert(
ulong valueRecordId;
ds.doTransaction(() {
AccountValueRecord record = valueRecordRepo.insert(
timestamp,
account.id,
type,
payload.value,
account.currency
);
writeJsonBody(response, AccountValueRecordResponse.of(record));
foreach (attachment; fullPayload.files) {
ulong attachmentId = attachmentRepo.save(
timestamp, attachment.name, attachment.contentType, attachment.content);
valueRecordRepo.linkAttachment(record.id, attachmentId);
}
valueRecordId = record.id;
});
writeJsonBody(
response,
AccountValueRecordResponse.of(
valueRecordRepo.findById(accountId, valueRecordId).orElseThrow(),
attachmentRepo
)
);
}
void handleDeleteValueRecord(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ulong accountId = request.getPathParamAs!ulong("accountId");
ulong valueRecordId = request.getPathParamAs!ulong("valueRecordId");
ProfileDataSource ds = getProfileDataSource(request);
ds.getAccountValueRecordRepository()
.deleteById(accountId, valueRecordId);
AccountValueRecordRepository valueRecordRepo = ds.getAccountValueRecordRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
AccountValueRecord valueRecord = valueRecordRepo.findById(accountId, valueRecordId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ds.doTransaction(() {
// First delete all attachments.
foreach (a; attachmentRepo.findAllByValueRecordId(valueRecord.id)) {
attachmentRepo.remove(a.id);
}
valueRecordRepo.deleteById(accountId, valueRecordId);
});
}

View File

@ -46,6 +46,7 @@ interface AccountValueRecordRepository {
long value,
Currency currency
);
void linkAttachment(ulong valueRecordId, ulong attachmentId);
Page!AccountValueRecord findAllByAccountId(ulong accountId, in PageRequest pr);
void deleteById(ulong accountId, ulong id);
Optional!AccountValueRecord findNearestByAccountIdBefore(ulong accountId, SysTime timestamp);

View File

@ -335,6 +335,15 @@ class SqliteAccountValueRecordRepository : AccountValueRecordRepository {
return findById(accountId, id).orElseThrow();
}
void linkAttachment(ulong valueRecordId, ulong attachmentId) {
util.sqlite.update(
db,
"INSERT INTO account_value_record_attachment (value_record_id, attachment_id) VALUES (?, ?)",
valueRecordId,
attachmentId
);
}
void deleteById(ulong accountId, ulong id) {
util.sqlite.update(
db,

View File

@ -7,6 +7,8 @@ import std.datetime;
interface AttachmentRepository {
Optional!Attachment findById(ulong id);
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId);
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content);
Attachment[] findAllByTransactionId(ulong transactionId);
Attachment[] findAllByValueRecordId(ulong valueRecordId);
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content);
void remove(ulong id);
}

View File

@ -19,18 +19,34 @@ class SqliteAttachmentRepository : AttachmentRepository {
Optional!Attachment findById(ulong id) {
return findOne(
db,
"SELECT * FROM attachment WHERE id = ?",
"SELECT id, uploaded_at, filename, content_type, size FROM attachment WHERE id = ?",
&parseAttachment,
id
);
}
Attachment[] findAllByLinkedEntity(string subquery, ulong entityId) {
const query = format!"SELECT * FROM attachment WHERE id IN (%s)"(subquery);
const query = format!("SELECT id, uploaded_at, filename, content_type, size " ~
"FROM attachment WHERE id IN (%s) " ~
"ORDER BY filename ASC, uploaded_at DESC")(subquery);
return findAll(db, query, &parseAttachment, entityId);
}
ulong save(SysTime uploadedAt, string filename, string contentType, ubyte[] content) {
Attachment[] findAllByTransactionId(ulong transactionId) {
return findAllByLinkedEntity(
"SELECT attachment_id FROM transaction_attachment WHERE transaction_id = ?",
transactionId
);
}
Attachment[] findAllByValueRecordId(ulong valueRecordId) {
return findAllByLinkedEntity(
"SELECT attachment_id FROM account_value_record_attachment WHERE value_record_id = ?",
valueRecordId
);
}
ulong save(SysTime uploadedAt, string filename, string contentType, in ubyte[] content) {
util.sqlite.update(
db,
q"SQL
@ -61,8 +77,7 @@ SQL",
parseISOTimestamp(row, 1),
row.peek!string(2),
row.peek!string(3),
row.peek!ulong(4),
parseBlob(row, 5)
row.peek!ulong(4)
);
}
}

View File

@ -0,0 +1,21 @@
module attachment.dto;
import attachment.model;
struct AttachmentResponse {
ulong id;
string uploadedAt;
string filename;
string contentType;
ulong size;
static AttachmentResponse of(Attachment a) {
return AttachmentResponse(
a.id,
a.uploadedAt.toISOExtString(),
a.filename,
a.contentType,
a.size
);
}
}

View File

@ -8,5 +8,4 @@ struct Attachment {
immutable string filename;
immutable string contentType;
immutable ulong size;
immutable ubyte[] content;
}

View File

@ -28,8 +28,10 @@ interface PropertiesRepository {
interface ProfileDataSource {
import account.data;
import transaction.data;
import attachment.data;
PropertiesRepository getPropertiesRepository();
AttachmentRepository getAttachmentRepository();
AccountRepository getAccountRepository();
AccountJournalEntryRepository getAccountJournalEntryRepository();

View File

@ -142,6 +142,8 @@ class SqliteProfileDataSource : ProfileDataSource {
import account.data_impl_sqlite;
import transaction.data;
import transaction.data_impl_sqlite;
import attachment.data;
import attachment.data_impl_sqlite;
const SCHEMA = import("sql/schema.sql");
private const string dbPath;
@ -159,35 +161,39 @@ class SqliteProfileDataSource : ProfileDataSource {
}
}
PropertiesRepository getPropertiesRepository() {
PropertiesRepository getPropertiesRepository() return scope {
return new SqlitePropertiesRepository(db);
}
AccountRepository getAccountRepository() {
AttachmentRepository getAttachmentRepository() return scope {
return new SqliteAttachmentRepository(db);
}
AccountRepository getAccountRepository() return scope {
return new SqliteAccountRepository(db);
}
AccountJournalEntryRepository getAccountJournalEntryRepository() {
AccountJournalEntryRepository getAccountJournalEntryRepository() return scope {
return new SqliteAccountJournalEntryRepository(db);
}
AccountValueRecordRepository getAccountValueRecordRepository() {
AccountValueRecordRepository getAccountValueRecordRepository() return scope {
return new SqliteAccountValueRecordRepository(db);
}
TransactionVendorRepository getTransactionVendorRepository() {
TransactionVendorRepository getTransactionVendorRepository() return scope {
return new SqliteTransactionVendorRepository(db);
}
TransactionCategoryRepository getTransactionCategoryRepository() {
TransactionCategoryRepository getTransactionCategoryRepository() return scope {
return new SqliteTransactionCategoryRepository(db);
}
TransactionTagRepository getTransactionTagRepository() {
TransactionTagRepository getTransactionTagRepository() return scope {
return new SqliteTransactionTagRepository(db);
}
TransactionRepository getTransactionRepository() {
TransactionRepository getTransactionRepository() return scope {
return new SqliteTransactionRepository(db);
}

View File

@ -37,20 +37,20 @@ void handleGetTransaction(ref ServerHttpRequest request, ref ServerHttpResponse
}
void handleAddTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
ProfileDataSource ds = getProfileDataSource(request);
auto payload = readJsonBodyAs!AddTransactionPayload(request);
TransactionDetail txn = addTransaction(ds, payload);
import asdf : serializeToJson;
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
ProfileDataSource ds = getProfileDataSource(request);
TransactionDetail txn = addTransaction(ds, fullPayload.payload, fullPayload.files);
string jsonStr = serializeToJson(txn);
response.writeBodyString(jsonStr, "application/json");
}
void handleUpdateTransaction(ref ServerHttpRequest request, ref ServerHttpResponse response) {
import asdf : serializeToJson;
ProfileDataSource ds = getProfileDataSource(request);
ulong txnId = getTransactionIdOrThrow(request);
auto payload = readJsonBodyAs!AddTransactionPayload(request);
TransactionDetail txn = updateTransaction(ds, txnId, payload);
import asdf : serializeToJson;
auto fullPayload = parseMultipartFilesAndBody!AddTransactionPayload(request);
TransactionDetail txn = updateTransaction(ds, txnId, fullPayload.payload, fullPayload.files);
string jsonStr = serializeToJson(txn);
response.writeBodyString(jsonStr, "application/json");
}

View File

@ -39,6 +39,7 @@ interface TransactionRepository {
Page!TransactionsListItem findAll(PageRequest pr);
Optional!TransactionDetail findById(ulong id);
TransactionDetail insert(in AddTransactionPayload data);
void linkAttachment(ulong transactionId, ulong attachmentId);
TransactionDetail update(ulong transactionId, in AddTransactionPayload data);
void deleteById(ulong id);
}

View File

@ -370,6 +370,15 @@ class SqliteTransactionRepository : TransactionRepository {
return findById(transactionId).orElseThrow();
}
void linkAttachment(ulong transactionId, ulong attachmentId) {
util.sqlite.update(
db,
"INSERT INTO transaction_attachment (transaction_id, attachment_id) VALUES (?, ?)",
transactionId,
attachmentId
);
}
TransactionDetail update(ulong transactionId, in AddTransactionPayload data) {
util.sqlite.update(
db,

View File

@ -5,6 +5,7 @@ import asdf : serdeTransformOut;
import std.typecons;
import transaction.model : TransactionCategory;
import attachment.dto;
import util.data;
import util.money;
@ -59,6 +60,7 @@ struct TransactionDetail {
Nullable!Account debitedAccount;
string[] tags;
LineItem[] lineItems;
AttachmentResponse[] attachments;
static struct Vendor {
ulong id;
@ -102,6 +104,7 @@ struct AddTransactionPayload {
Nullable!ulong debitedAccountId;
string[] tags;
LineItem[] lineItems;
ulong[] attachmentIdsToRemove;
static struct LineItem {
long valuePerItem;

View File

@ -13,7 +13,8 @@ import account.data;
import util.money;
import util.pagination;
import util.data;
import core.internal.container.common;
import attachment.data;
import attachment.dto;
// Transactions Services
@ -24,14 +25,22 @@ Page!TransactionsListItem getTransactions(ProfileDataSource ds, in PageRequest p
}
TransactionDetail getTransaction(ProfileDataSource ds, ulong transactionId) {
return ds.getTransactionRepository().findById(transactionId)
import std.algorithm : map;
import std.array : array;
TransactionDetail txn = ds.getTransactionRepository().findById(transactionId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
txn.attachments = ds.getAttachmentRepository().findAllByTransactionId(txn.id)
.map!(AttachmentResponse.of)
.array;
return txn;
}
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload) {
TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload payload, in MultipartFile[] files) {
TransactionRepository txnRepo = ds.getTransactionRepository();
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
AccountRepository accountRepo = ds.getAccountRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
@ -39,8 +48,7 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
// Add the transaction:
ulong txnId;
ds.doTransaction(() {
TransactionRepository txRepo = ds.getTransactionRepository();
TransactionDetail txn = txRepo.insert(payload);
TransactionDetail txn = txnRepo.insert(payload);
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
if (!payload.creditedAccountId.isNull) {
jeRepo.insert(
@ -64,18 +72,25 @@ TransactionDetail addTransaction(ProfileDataSource ds, in AddTransactionPayload
}
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
tagRepo.updateTags(txn.id, payload.tags);
updateAttachments(txn.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, txnRepo);
txnId = txn.id;
});
return ds.getTransactionRepository().findById(txnId).orElseThrow();
return getTransaction(ds, txnId);
}
TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, in AddTransactionPayload payload) {
TransactionDetail updateTransaction(
ProfileDataSource ds,
ulong transactionId,
in AddTransactionPayload payload,
in MultipartFile[] files
) {
TransactionVendorRepository vendorRepo = ds.getTransactionVendorRepository();
TransactionCategoryRepository categoryRepo = ds.getTransactionCategoryRepository();
AccountRepository accountRepo = ds.getAccountRepository();
TransactionRepository transactionRepo = ds.getTransactionRepository();
AccountJournalEntryRepository jeRepo = ds.getAccountJournalEntryRepository();
TransactionTagRepository tagRepo = ds.getTransactionTagRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
validateTransactionPayload(vendorRepo, categoryRepo, accountRepo, payload);
SysTime timestamp = SysTime.fromISOExtString(payload.timestamp, UTC());
@ -122,17 +137,24 @@ TransactionDetail updateTransaction(ProfileDataSource ds, ulong transactionId, i
);
}
// Update tags.
tagRepo.updateTags(transactionId, payload.tags);
updateAttachments(curr.id, timestamp, payload.attachmentIdsToRemove, files, attachmentRepo, transactionRepo);
});
return transactionRepo.findById(transactionId).orElseThrow();
return getTransaction(ds, transactionId);
}
void deleteTransaction(ProfileDataSource ds, ulong transactionId) {
TransactionRepository txnRepo = ds.getTransactionRepository();
AttachmentRepository attachmentRepo = ds.getAttachmentRepository();
TransactionDetail txn = txnRepo.findById(transactionId)
.orElseThrow(() => new HttpStatusException(HttpStatus.NOT_FOUND));
ds.doTransaction(() {
// First delete all attachments.
foreach (a; attachmentRepo.findAllByTransactionId(txn.id)) {
attachmentRepo.remove(a.id);
}
txnRepo.deleteById(txn.id);
});
}
private void validateTransactionPayload(
@ -205,6 +227,28 @@ private void validateTransactionPayload(
}
}
/**
* Helper function to add / remove attachments for a transaction.
*/
void updateAttachments(
ulong transactionId,
SysTime timestamp,
in ulong[] attachmentIdsToRemove,
in MultipartFile[] attachmentsToAdd,
AttachmentRepository attachmentRepo,
TransactionRepository txnRepo
) {
// Save & link attachment files:
foreach (file; attachmentsToAdd) {
ulong attachmentId = attachmentRepo.save(timestamp, file.name, file.contentType, file.content);
txnRepo.linkAttachment(transactionId, attachmentId);
}
// Delete attachments (this cascades to delete the link record in transaction_attachment).
foreach (idToRemove; attachmentIdsToRemove) {
attachmentRepo.remove(idToRemove);
}
}
// Vendors Services
TransactionVendor[] getAllVendors(ProfileDataSource ds) {

View File

@ -1,6 +1,7 @@
module util.data;
import handy_http_primitives;
import handy_http_data.multipart;
import std.typecons;
Optional!T toOptional(T)(Nullable!T value) {
@ -41,3 +42,86 @@ ulong getPathParamOrThrow(T = ulong)(in ServerHttpRequest req, string name) {
// No params matched, so throw a NOT FOUND error.
throw new HttpStatusException(HttpStatus.NOT_FOUND, "Missing required path parameter \"" ~ name ~ "\".");
}
struct MultipartFile {
string name;
string contentType;
ubyte[] content;
}
struct MultipartFilesAndBody(T) {
T payload;
MultipartFile[] files;
}
MultipartFilesAndBody!T parseMultipartFilesAndBody(T)(
ref ServerHttpRequest request,
string payloadPartName = "payload",
string filesPartName = "file"
) {
import asdf : deserialize, SerdeException;
import std.uni : toLower;
import std.array;
MultipartFormData formData;
try {
formData = readBodyAsMultipartFormData(request);
} catch (MultipartFormatException e) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, "Invalid multipart form data.");
}
MultipartFilesAndBody!T result;
RefAppender!(MultipartFile[]) app = appender(&result.files);
foreach (element; formData.elements) {
if (toLower(element.name) == payloadPartName) {
try {
result.payload = deserialize!T(element.content);
} catch (SerdeException e) {
throw new HttpStatusException(HttpStatus.BAD_REQUEST, e.msg);
}
} else if (toLower(element.name) == filesPartName) {
app ~= parseMultipartFile(element);
}
}
return result;
}
private MultipartFile parseMultipartFile(in MultipartElement e) {
import std.algorithm : splitter, count;
import std.array;
import std.string : strip;
const requiredHeaders = ["Content-Disposition", "Content-Type"];
static foreach (headerName; requiredHeaders) {
if (headerName !in e.headers || e.headers[headerName].length < 1) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Missing required \"" ~ headerName ~ "\" header for multipart file."
);
}
}
if ("Content-Disposition" !in e.headers) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Missing required \"Content-Disposition\" header for multipart file."
);
}
string filename;
foreach (part; e.headers["Content-Disposition"].splitter(";")) {
string[] partSegments = part.splitter("=").array;
if (count(partSegments) == 2 && partSegments[0].strip() == "filename") {
filename = partSegments[1].strip().strip("\"");
break;
}
}
if (filename is null || filename.length < 1) {
throw new HttpStatusException(
HttpStatus.BAD_REQUEST,
"Missing filename for multipart file."
);
}
return MultipartFile(
filename,
e.headers["Content-Type"],
cast(ubyte[]) e.content
);
}

View File

@ -125,7 +125,7 @@ struct Page(T) {
bool isLast;
Page!U mapTo(U)(U function(T) fn) {
Page!U mapTo(U)(U delegate(T) fn) {
import std.algorithm : map;
import std.array : array;
return Page!(U)(items.map!(fn).array, pageRequest, totalElements, totalPages, isFirst, isLast);

View File

@ -202,7 +202,7 @@ void generateRandomTransactions(ProfileDataSource ds) {
}
}
auto txn = addTransaction(ds, data);
auto txn = addTransaction(ds, data, []);
infoF!" Generated transaction %d"(txn.id);
timestamp -= seconds(uniform(10, 1_000_000));
}

View File

@ -47,7 +47,7 @@ export interface Account {
type: string
numberSuffix: string
name: string
currency: string
currency: Currency
description: string
currentBalance: number | null
}
@ -133,8 +133,14 @@ export class AccountApiClient extends ApiClient {
createValueRecord(
accountId: number,
payload: AccountValueRecordCreationPayload,
attachments: File[],
): Promise<AccountValueRecord> {
return super.postJson(this.path + '/' + accountId + '/value-records', payload)
const formData = new FormData()
formData.append('payload', JSON.stringify(payload))
for (const file of attachments) {
formData.append('file', file)
}
return super.postFormData(`${this.path}/${accountId}/value-records`, formData)
}
deleteValueRecord(accountId: number, valueRecordId: number): Promise<void> {

View File

@ -49,6 +49,37 @@ export abstract class ApiClient {
return await r.json()
}
protected async postFormData<T>(path: string, body: FormData): Promise<T> {
try {
const authStore = useAuthStore()
const response = await fetch(this.baseUrl + path, {
headers: {
Authorization: 'Bearer ' + authStore.state?.token,
},
body: body,
method: 'POST',
})
if (!response.ok) {
const message = await response.text()
throw new StatusError(response.status, message)
}
return await response.json()
} catch (error) {
if (error instanceof ApiError) throw error
let message = 'Request to ' + path + ' failed.'
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
message = error.message
}
console.error(error)
throw new NetworkError(message)
}
}
protected async postText(path: string, body: object | undefined = undefined): Promise<string> {
const r = await this.doRequest('POST', path, body)
return await r.text()
@ -70,6 +101,37 @@ export abstract class ApiClient {
return await r.json()
}
protected async putFormData<T>(path: string, body: FormData): Promise<T> {
try {
const authStore = useAuthStore()
const response = await fetch(this.baseUrl + path, {
headers: {
Authorization: 'Bearer ' + authStore.state?.token,
},
body: body,
method: 'PUT',
})
if (!response.ok) {
const message = await response.text()
throw new StatusError(response.status, message)
}
return await response.json()
} catch (error) {
if (error instanceof ApiError) throw error
let message = 'Request to ' + path + ' failed.'
if (
typeof error === 'object' &&
error !== null &&
'message' in error &&
typeof error.message === 'string'
) {
message = error.message
}
console.error(error)
throw new NetworkError(message)
}
}
async getApiStatus(): Promise<boolean> {
try {
await this.doRequest('GET', '/status')

View File

@ -87,6 +87,7 @@ export interface TransactionDetail {
debitedAccount: TransactionDetailAccount | null
tags: string[]
lineItems: TransactionDetailLineItem[]
attachments: TransactionDetailAttachment[]
}
export interface TransactionDetailAccount {
@ -104,6 +105,14 @@ export interface TransactionDetailLineItem {
category: TransactionCategory | null
}
export interface TransactionDetailAttachment {
id: number
uploadedAt: string
filename: string
contentType: string
size: number
}
export interface AddTransactionPayload {
timestamp: string
amount: number
@ -115,6 +124,7 @@ export interface AddTransactionPayload {
debitedAccountId: number | null
tags: string[]
lineItems: AddTransactionPayloadLineItem[]
attachmentIdsToRemove: number[]
}
export interface AddTransactionPayloadLineItem {
@ -205,12 +215,26 @@ export class TransactionApiClient extends ApiClient {
return super.getJson(this.path + '/transactions/' + id)
}
addTransaction(data: AddTransactionPayload): Promise<TransactionDetail> {
return super.postJson(this.path + '/transactions', data)
addTransaction(data: AddTransactionPayload, files: File[] = []): Promise<TransactionDetail> {
const formData = new FormData()
formData.append('payload', JSON.stringify(data))
for (const file of files) {
formData.append('file', file)
}
return super.postFormData(this.path + '/transactions', formData)
}
updateTransaction(id: number, data: AddTransactionPayload): Promise<TransactionDetail> {
return super.putJson(this.path + '/transactions/' + id, data)
updateTransaction(
id: number,
data: AddTransactionPayload,
files: File[] = [],
): Promise<TransactionDetail> {
const formData = new FormData()
formData.append('payload', JSON.stringify(data))
for (const file of files) {
formData.append('file', file)
}
return super.putFormData(this.path + '/transactions/' + id, formData)
}
deleteTransaction(id: number): Promise<void> {

View File

@ -8,6 +8,7 @@ import AppButton from './AppButton.vue';
import { AccountApiClient, AccountValueRecordType, type Account, type AccountValueRecord, type AccountValueRecordCreationPayload } from '@/api/account';
import { useProfileStore } from '@/stores/profile-store';
import { datetimeLocalToISO, getDatetimeLocalValueForNow } from '@/util/time';
import FileSelector from './FileSelector.vue';
const props = defineProps<{ account: Account }>()
const profileStore = useProfileStore()
@ -17,6 +18,7 @@ const savedValueRecord: Ref<AccountValueRecord | undefined> = ref(undefined)
// Form data:
const timestamp = ref('')
const amount = ref(0)
const attachments: Ref<File[]> = ref([])
async function show(): Promise<AccountValueRecord | undefined> {
if (!modal.value) return Promise.resolve(undefined)
@ -39,7 +41,7 @@ async function addValueRecord() {
}
const api = new AccountApiClient(profileStore.state)
try {
savedValueRecord.value = await api.createValueRecord(props.account.id, payload)
savedValueRecord.value = await api.createValueRecord(props.account.id, payload, attachments.value)
modal.value?.close('saved')
} catch (err) {
console.error(err)
@ -66,6 +68,10 @@ defineExpose({ show })
<input type="number" v-model="amount" step="0.01" />
</FormControl>
</FormGroup>
<FormGroup>
<h5>Attachments</h5>
<FileSelector v-model:uploaded-files="attachments" />
</FormGroup>
</AppForm>
</template>
<template v-slot:buttons>

View File

@ -0,0 +1,137 @@
<script setup lang="ts">
import { onMounted, ref, useTemplateRef, watch, type Ref } from 'vue';
import AppButton from './AppButton.vue';
interface ExistingFile {
id: number
filename: string
contentType: string
size: number
}
abstract class FileListItem {
constructor(
public readonly filename: string,
public readonly contentType: string,
public readonly size: number
) { }
}
class ExistingFileListItem extends FileListItem {
public readonly id: number
constructor(file: ExistingFile) {
super(file.filename, file.contentType, file.size)
this.id = file.id
}
}
class NewFileListItem extends FileListItem {
public readonly file: File
constructor(file: File) {
super(file.name, file.type, file.size)
this.file = file
}
}
interface Props {
disabled?: boolean,
initialFiles?: ExistingFile[]
}
const props = withDefaults(defineProps<Props>(), {
disabled: false,
initialFiles: () => []
})
const fileInput = useTemplateRef('fileInput')
const uploadedFiles = defineModel<File[]>('uploaded-files', { default: [] })
const removedFiles = defineModel<number[]>('removed-files', { default: [] })
// Internal file list model:
const files: Ref<FileListItem[]> = ref([])
onMounted(() => {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
// If input initial files change, reset the file selector to just those.
watch(() => props.initialFiles, () => {
files.value = props.initialFiles.map(f => new ExistingFileListItem(f))
})
// When our internal model changes, update the defined uploaded/removed files models.
watch(() => files, () => {
// Compute the set of uploaded files as just any newly uploaded file list item.
uploadedFiles.value = files.value.filter(f => f instanceof NewFileListItem).map(f => f.file)
// Compute the set of removed files as those from the set of initial files whose ID is no longer present.
const retainedExistingFileIds = files.value
.filter(f => f instanceof ExistingFileListItem)
.map(f => f.id)
removedFiles.value = props.initialFiles
.filter(f => !retainedExistingFileIds.includes(f.id))
.map(f => f.id)
}, { deep: true })
})
function onFileInputChanged(e: Event) {
if (props.disabled) return
const inputElement = e.target as HTMLInputElement
const fileList = inputElement.files
if (fileList === null) {
return
}
for (let i = 0; i < fileList?.length; i++) {
const file = fileList.item(i)
if (file !== null) {
files.value = [...files.value, new NewFileListItem(file)]
}
}
// Reset the input element after we've consumed the selected files.
inputElement.value = ''
}
function onFileDeleteClicked(idx: number) {
if (props.disabled) return
if (idx === 0 && files.value.length === 1) {
files.value = []
return
}
files.value.splice(idx, 1)
}
</script>
<template>
<div class="file-selector">
<div @click.prevent="">
<div v-for="file, idx in files" :key="idx" class="file-selector-item">
<div style="display: flex; align-items: center; margin-left: 1rem;">
<div>
<div>{{ file.filename }}</div>
<div style="font-size: 0.75rem;">{{ file.contentType }}</div>
</div>
</div>
<div>
<AppButton v-if="!disabled" icon="trash" button-type="button" @click="onFileDeleteClicked(idx)" />
</div>
</div>
</div>
<div>
<input id="fileInput" type="file" multiple @change="onFileInputChanged" style="display: none;" ref="fileInput"
:disabled="disabled" />
<label for="fileInput">
<AppButton icon="upload" button-type="button" @click="fileInput?.click()" :disabled="disabled">Select a File
</AppButton>
</label>
</div>
</div>
</template>
<style lang="css">
.file-selector {
width: 100%;
}
.file-selector-item {
background-color: var(--bg-secondary);
display: flex;
justify-content: space-between;
padding: 0.25rem;
border-radius: 0.5rem;
margin: 0.5rem 0;
}
</style>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account';
import { formatMoney } from '@/api/data';
import AddValueRecordModal from '@/components/AddValueRecordModal.vue';
import AppButton from '@/components/AppButton.vue';
import AppPage from '@/components/AppPage.vue';
@ -81,12 +82,16 @@ async function addValueRecord() {
</tr>
<tr>
<th>Currency</th>
<td>{{ account.currency }}</td>
<td>{{ account.currency.code }}</td>
</tr>
<tr>
<th>Description</th>
<td>{{ account.description }}</td>
</tr>
<tr v-if="account.currentBalance">
<th>Current Balance</th>
<td>{{ formatMoney(account.currentBalance, account.currency) }}</td>
</tr>
</PropertiesTable>
<div>
<AppButton @click="addValueRecord()">Record Value</AppButton>

View File

@ -15,6 +15,7 @@ import { DataApiClient, type Currency } from '@/api/data';
import { TransactionApiClient, type AddTransactionPayload, type TransactionDetail, type TransactionDetailLineItem, type TransactionVendor } from '@/api/transaction';
import AppPage from '@/components/AppPage.vue';
import CategorySelect from '@/components/CategorySelect.vue';
import FileSelector from '@/components/FileSelector.vue';
import AppForm from '@/components/form/AppForm.vue';
import FormActions from '@/components/form/FormActions.vue';
import FormControl from '@/components/form/FormControl.vue';
@ -65,6 +66,8 @@ const tags: Ref<string[]> = ref([])
const selectedTagToAdd: Ref<string | null> = ref(null)
const customTagInput = ref('')
const customTagInputValid = ref(false)
const attachmentsToUpload: Ref<File[]> = ref([])
const removedAttachmentIds: Ref<number[]> = ref([])
watch(customTagInput, (newValue: string) => {
const result = newValue.match("^[a-z0-9-_]{3,32}$")
@ -130,7 +133,8 @@ async function doSubmit() {
tags: tags.value,
lineItems: lineItems.value.map(i => {
return { ...i, categoryId: i.category?.id ?? null }
})
}),
attachmentIdsToRemove: removedAttachmentIds.value
}
const transactionApi = new TransactionApiClient()
@ -138,9 +142,9 @@ async function doSubmit() {
try {
loading.value = true
if (existingTransaction.value) {
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload)
savedTransaction = await transactionApi.updateTransaction(existingTransaction.value?.id, payload, attachmentsToUpload.value)
} else {
savedTransaction = await transactionApi.addTransaction(payload)
savedTransaction = await transactionApi.addTransaction(payload, attachmentsToUpload.value)
}
await router.replace(`/profiles/${profileStore.state.name}/transactions/${savedTransaction.id}`)
} catch (err) {
@ -237,6 +241,7 @@ function isEdited() {
}
}
}
const attachmentsChanged = attachmentsToUpload.value.length > 0 || removedAttachmentIds.value.length > 0
return new Date(timestamp.value).toISOString() !== existingTransaction.value.timestamp ||
amount.value * Math.pow(10, currency.value?.fractionalDigits ?? 0) !== existingTransaction.value.amount ||
@ -247,7 +252,8 @@ function isEdited() {
creditedAccountId.value !== (existingTransaction.value.creditedAccount?.id ?? null) ||
debitedAccountId.value !== (existingTransaction.value.debitedAccount?.id ?? null) ||
!tagsEqual ||
!lineItemsEqual
!lineItemsEqual ||
attachmentsChanged
}
</script>
<template>
@ -327,6 +333,12 @@ function isEdited() {
</FormControl>
</FormGroup>
<FormGroup>
<h5>Attachments</h5>
<FileSelector :initial-files="existingTransaction?.attachments ?? []"
v-model:uploaded-files="attachmentsToUpload" v-model:removed-files="removedAttachmentIds" />
</FormGroup>
<FormActions @cancel="doCancel()" :disabled="loading || !isFormValid() || !isEdited()"
:submit-text="editing ? 'Save' : 'Add'" />
</AppForm>

View File

@ -1,5 +1,6 @@
<script setup lang="ts">
import { AccountApiClient, type Account } from '@/api/account'
import { formatMoney } from '@/api/data'
import AppButton from '@/components/AppButton.vue'
import HomeModule from '@/components/HomeModule.vue'
import { useProfileStore } from '@/stores/profile-store'
@ -32,6 +33,7 @@ onMounted(async () => {
<th>Currency</th>
<th>Number</th>
<th>Type</th>
<th>Balance</th>
</tr>
</thead>
<tbody>
@ -40,9 +42,13 @@ onMounted(async () => {
<RouterLink :to="`/profiles/${profileStore.state?.name}/accounts/${account.id}`">{{ account.name }}
</RouterLink>
</td>
<td>{{ account.currency }}</td>
<td>{{ account.currency.code }}</td>
<td>...{{ account.numberSuffix }}</td>
<td>{{ account.type }}</td>
<td>
<span v-if="account.currentBalance">{{ formatMoney(account.currentBalance, account.currency) }}</span>
<span v-if="!account.currentBalance">Unknown</span>
</td>
</tr>
</tbody>
</table>