diff --git a/finnow-api/source/account/api.d b/finnow-api/source/account/api.d index e90c784..92e9ca5 100644 --- a/finnow-api/source/account/api.d +++ b/finnow-api/source/account/api.d @@ -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( - timestamp, - account.id, - type, - payload.value, - account.currency + ulong valueRecordId; + ds.doTransaction(() { + AccountValueRecord record = valueRecordRepo.insert( + timestamp, + account.id, + type, + payload.value, + account.currency + ); + 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 + ) ); - writeJsonBody(response, AccountValueRecordResponse.of(record)); } 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); + }); } diff --git a/finnow-api/source/account/data.d b/finnow-api/source/account/data.d index 2bc0f37..69b779d 100644 --- a/finnow-api/source/account/data.d +++ b/finnow-api/source/account/data.d @@ -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); diff --git a/finnow-api/source/account/data_impl_sqlite.d b/finnow-api/source/account/data_impl_sqlite.d index cebe9c2..193ae73 100644 --- a/finnow-api/source/account/data_impl_sqlite.d +++ b/finnow-api/source/account/data_impl_sqlite.d @@ -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, diff --git a/finnow-api/source/attachment/data.d b/finnow-api/source/attachment/data.d index 73e629b..9644364 100644 --- a/finnow-api/source/attachment/data.d +++ b/finnow-api/source/attachment/data.d @@ -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); } diff --git a/finnow-api/source/attachment/data_impl_sqlite.d b/finnow-api/source/attachment/data_impl_sqlite.d index 937d4b7..b4a64a4 100644 --- a/finnow-api/source/attachment/data_impl_sqlite.d +++ b/finnow-api/source/attachment/data_impl_sqlite.d @@ -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); } + + 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, ubyte[] content) { + 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) ); } } diff --git a/finnow-api/source/attachment/dto.d b/finnow-api/source/attachment/dto.d new file mode 100644 index 0000000..7d55586 --- /dev/null +++ b/finnow-api/source/attachment/dto.d @@ -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 + ); + } +} \ No newline at end of file diff --git a/finnow-api/source/attachment/model.d b/finnow-api/source/attachment/model.d index 62f1dbc..aa40362 100644 --- a/finnow-api/source/attachment/model.d +++ b/finnow-api/source/attachment/model.d @@ -8,5 +8,4 @@ struct Attachment { immutable string filename; immutable string contentType; immutable ulong size; - immutable ubyte[] content; } diff --git a/finnow-api/source/profile/data.d b/finnow-api/source/profile/data.d index 84d153a..ee411ef 100644 --- a/finnow-api/source/profile/data.d +++ b/finnow-api/source/profile/data.d @@ -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(); diff --git a/finnow-api/source/profile/data_impl_sqlite.d b/finnow-api/source/profile/data_impl_sqlite.d index 5fe6550..c2f5869 100644 --- a/finnow-api/source/profile/data_impl_sqlite.d +++ b/finnow-api/source/profile/data_impl_sqlite.d @@ -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); } diff --git a/finnow-api/source/transaction/api.d b/finnow-api/source/transaction/api.d index 970898e..262ce2b 100644 --- a/finnow-api/source/transaction/api.d +++ b/finnow-api/source/transaction/api.d @@ -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"); } diff --git a/finnow-api/source/transaction/data.d b/finnow-api/source/transaction/data.d index 06ab6eb..39afb9c 100644 --- a/finnow-api/source/transaction/data.d +++ b/finnow-api/source/transaction/data.d @@ -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); } diff --git a/finnow-api/source/transaction/data_impl_sqlite.d b/finnow-api/source/transaction/data_impl_sqlite.d index eac85a6..60c61ee 100644 --- a/finnow-api/source/transaction/data_impl_sqlite.d +++ b/finnow-api/source/transaction/data_impl_sqlite.d @@ -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, diff --git a/finnow-api/source/transaction/dto.d b/finnow-api/source/transaction/dto.d index a836781..35c33d3 100644 --- a/finnow-api/source/transaction/dto.d +++ b/finnow-api/source/transaction/dto.d @@ -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; diff --git a/finnow-api/source/transaction/service.d b/finnow-api/source/transaction/service.d index d69157b..34167b3 100644 --- a/finnow-api/source/transaction/service.d +++ b/finnow-api/source/transaction/service.d @@ -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)); - txnRepo.deleteById(txn.id); + 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) { diff --git a/finnow-api/source/util/data.d b/finnow-api/source/util/data.d index c518f84..cf92fc1 100644 --- a/finnow-api/source/util/data.d +++ b/finnow-api/source/util/data.d @@ -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 + ); +} diff --git a/finnow-api/source/util/pagination.d b/finnow-api/source/util/pagination.d index 95f7311..84864ed 100644 --- a/finnow-api/source/util/pagination.d +++ b/finnow-api/source/util/pagination.d @@ -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); diff --git a/finnow-api/source/util/sample_data.d b/finnow-api/source/util/sample_data.d index b3412dc..8bb2f4f 100644 --- a/finnow-api/source/util/sample_data.d +++ b/finnow-api/source/util/sample_data.d @@ -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)); } diff --git a/web-app/src/api/account.ts b/web-app/src/api/account.ts index eb48f6e..3ff9e86 100644 --- a/web-app/src/api/account.ts +++ b/web-app/src/api/account.ts @@ -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 { - 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 { diff --git a/web-app/src/api/base.ts b/web-app/src/api/base.ts index 07df891..7e43aa6 100644 --- a/web-app/src/api/base.ts +++ b/web-app/src/api/base.ts @@ -49,6 +49,37 @@ export abstract class ApiClient { return await r.json() } + protected async postFormData(path: string, body: FormData): Promise { + 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 { 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(path: string, body: FormData): Promise { + 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 { try { await this.doRequest('GET', '/status') diff --git a/web-app/src/api/transaction.ts b/web-app/src/api/transaction.ts index aac8dc8..021beea 100644 --- a/web-app/src/api/transaction.ts +++ b/web-app/src/api/transaction.ts @@ -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 { - return super.postJson(this.path + '/transactions', data) + addTransaction(data: AddTransactionPayload, files: File[] = []): Promise { + 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 { - return super.putJson(this.path + '/transactions/' + id, data) + updateTransaction( + id: number, + data: AddTransactionPayload, + files: File[] = [], + ): Promise { + 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 { diff --git a/web-app/src/components/AddValueRecordModal.vue b/web-app/src/components/AddValueRecordModal.vue index 897b035..dddd6b2 100644 --- a/web-app/src/components/AddValueRecordModal.vue +++ b/web-app/src/components/AddValueRecordModal.vue @@ -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 = ref(undefined) // Form data: const timestamp = ref('') const amount = ref(0) +const attachments: Ref = ref([]) async function show(): Promise { 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 }) + +
Attachments
+ +