diff --git a/README.md b/README.md index f292af3..505b1ad 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Start the API: ```bash npm run start:dev ``` -Verify running via the [healthcheck](http://localhost:3000/common-cents/healthcheck) endpoint. +To verify the API is running access the interactive [documentation](http://localhost:3000/common-cents/docs). Default port (`3000`) can be changed by setting the `PORT=` environment variable. diff --git a/bruno/Common Cents/Categories/LOC POST Category.bru b/bruno/Common Cents/Categories/LOC POST Category.bru index c8d1602..97251b0 100644 --- a/bruno/Common Cents/Categories/LOC POST Category.bru +++ b/bruno/Common Cents/Categories/LOC POST Category.bru @@ -12,6 +12,6 @@ post { body:json { { - "name": "First Cat" + "name": "Gas" } } diff --git a/bruno/Common Cents/Expenses/LOC POST Expense.bru b/bruno/Common Cents/Expenses/LOC POST Expense.bru index 38c0cf5..53b9456 100644 --- a/bruno/Common Cents/Expenses/LOC POST Expense.bru +++ b/bruno/Common Cents/Expenses/LOC POST Expense.bru @@ -12,27 +12,10 @@ post { body:json { { - "year": "2026", - "month": "01", - "day": "02", - "cents": 1000, - "description": "With cat, no subcat or merchant or tags", - "category": { - "id": "72644e00-f1fa-4029-bd9c-2e82eb965aeb" - } - // "subCategory": { - // "id": "270ceaea-9cb8-4c6a-846f-ea35ed4d12f7" - // }, - // "merchant": { - // "id": "61246db4-3110-4fe2-bdab-d819b8fd0705" - // }, - // "tags": [ - // { - // "id": "2616f724-f3ce-46df-8b30-897f147f6b74" - // }, - // { - // "id": "16ed84e0-2d17-45c7-ada8-6fe7f8cc8720" - // } - // ] + "date": "2026-01-03", + "cents": 101111, + "note": "Gas", + "categoryId": "db21acbb-0e3e-4ea1-89e1-3d52e5d72cb4", + "merchantId": "b9028a32-1305-4699-8611-d8fd812ebc04" } } diff --git a/bruno/Common Cents/Expenses/folder.bru b/bruno/Common Cents/Expenses/folder.bru index 5e0ac02..80ea79e 100644 --- a/bruno/Common Cents/Expenses/folder.bru +++ b/bruno/Common Cents/Expenses/folder.bru @@ -1,5 +1,6 @@ meta { name: Expenses + seq: 3 } auth { diff --git a/bruno/Common Cents/Health.bru b/bruno/Common Cents/Healthcheck.bru similarity index 84% rename from bruno/Common Cents/Health.bru rename to bruno/Common Cents/Healthcheck.bru index 4432b1c..d56a5c3 100644 --- a/bruno/Common Cents/Health.bru +++ b/bruno/Common Cents/Healthcheck.bru @@ -1,5 +1,5 @@ meta { - name: Health + name: Healthcheck type: http seq: 1 } diff --git a/bruno/Common Cents/Merchants/LOC POST Merchant.bru b/bruno/Common Cents/Merchants/LOC POST Merchant.bru index 60e5466..b5665ab 100644 --- a/bruno/Common Cents/Merchants/LOC POST Merchant.bru +++ b/bruno/Common Cents/Merchants/LOC POST Merchant.bru @@ -12,6 +12,6 @@ post { body:json { { - "name": "Merchant Three" + "name": "Casey's" } } diff --git a/bruno/Common Cents/Merchants/folder.bru b/bruno/Common Cents/Merchants/folder.bru index dc13882..c7dce86 100644 --- a/bruno/Common Cents/Merchants/folder.bru +++ b/bruno/Common Cents/Merchants/folder.bru @@ -1,6 +1,6 @@ meta { name: Merchants - seq: 3 + seq: 4 } auth { diff --git a/bruno/Common Cents/Sub-categories/LOC DELETE Sub-category.bru b/bruno/Common Cents/Sub-categories/LOC DELETE Sub-category.bru deleted file mode 100644 index c10a511..0000000 --- a/bruno/Common Cents/Sub-categories/LOC DELETE Sub-category.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: LOC DELETE Sub-category - type: http - seq: 5 -} - -delete { - url: {{localBaseUrl}}/{{resourcePath}}/{{resourceId}} - body: none - auth: inherit -} - -vars:pre-request { - resourceId: cbf30070-9ff7-419f-a567-f7d145be445b -} diff --git a/bruno/Common Cents/Sub-categories/LOC GET Sub-categories.bru b/bruno/Common Cents/Sub-categories/LOC GET Sub-categories.bru deleted file mode 100644 index e2eb171..0000000 --- a/bruno/Common Cents/Sub-categories/LOC GET Sub-categories.bru +++ /dev/null @@ -1,11 +0,0 @@ -meta { - name: LOC GET Sub-categories - type: http - seq: 1 -} - -get { - url: {{localBaseUrl}}/{{resourcePath}} - body: none - auth: inherit -} diff --git a/bruno/Common Cents/Sub-categories/LOC GET Sub-category By ID.bru b/bruno/Common Cents/Sub-categories/LOC GET Sub-category By ID.bru deleted file mode 100644 index 6703655..0000000 --- a/bruno/Common Cents/Sub-categories/LOC GET Sub-category By ID.bru +++ /dev/null @@ -1,15 +0,0 @@ -meta { - name: LOC GET Sub-category By ID - type: http - seq: 2 -} - -get { - url: {{localBaseUrl}}/{{resourcePath}}/{{resourceId}} - body: none - auth: inherit -} - -vars:pre-request { - resourceId: 1d6d2842-b271-489b-bd93-e3ceaee5a139 -} diff --git a/bruno/Common Cents/Sub-categories/LOC POST Sub-category.bru b/bruno/Common Cents/Sub-categories/LOC POST Sub-category.bru deleted file mode 100644 index 2d221f4..0000000 --- a/bruno/Common Cents/Sub-categories/LOC POST Sub-category.bru +++ /dev/null @@ -1,17 +0,0 @@ -meta { - name: LOC POST Sub-category - type: http - seq: 3 -} - -post { - url: {{localBaseUrl}}/{{resourcePath}} - body: json - auth: inherit -} - -body:json { - { - "name": "Sub-category Three" - } -} diff --git a/bruno/Common Cents/Sub-categories/LOC PUT Sub-category.bru b/bruno/Common Cents/Sub-categories/LOC PUT Sub-category.bru deleted file mode 100644 index 060a9b0..0000000 --- a/bruno/Common Cents/Sub-categories/LOC PUT Sub-category.bru +++ /dev/null @@ -1,18 +0,0 @@ -meta { - name: LOC PUT Sub-category - type: http - seq: 4 -} - -put { - url: {{localBaseUrl}}/{{resourcePath}} - body: json - auth: inherit -} - -body:json { - { - "id": "1d6d2842-b271-489b-bd93-e3ceaee5a139", - "name": "Merchant One" - } -} diff --git a/bruno/Common Cents/Sub-categories/folder.bru b/bruno/Common Cents/Sub-categories/folder.bru deleted file mode 100644 index 47ece91..0000000 --- a/bruno/Common Cents/Sub-categories/folder.bru +++ /dev/null @@ -1,12 +0,0 @@ -meta { - name: Sub-categories - seq: 4 -} - -auth { - mode: inherit -} - -vars:pre-request { - resourcePath: sub-categories -} diff --git a/bruno/Common Cents/Tags/LOC POST Tag.bru b/bruno/Common Cents/Tags/LOC POST Tag.bru index 565a854..872b5e5 100644 --- a/bruno/Common Cents/Tags/LOC POST Tag.bru +++ b/bruno/Common Cents/Tags/LOC POST Tag.bru @@ -12,6 +12,6 @@ post { body:json { { - "name": "Tag Three" + "name": "Joe" } } diff --git a/bruno/Common Cents/seed-data.js b/bruno/Common Cents/seed-data.js new file mode 100644 index 0000000..56ebf46 --- /dev/null +++ b/bruno/Common Cents/seed-data.js @@ -0,0 +1,80 @@ +const postFakeCategories = [ + { + name: "Utilities" + }, + { + name: "Automotive:Gasoline" + }, + { + name: "Groceries:Food" + } +]; +const postFakeMerchants = [ + { + name: "Walmart" + }, + { + name: "Casey's" + }, + { + name: "Electric Company" + } +]; +const postFakeTags = [ + { + name: "Truck" + }, + { + name: "Van" + } +]; +const postFakeExpenses = [ + { + year: "2025", + month: "12", + day: "28", + cents: 12153, + category: { + name: "Utilities" + }, + merchant: { + name: "Electric Company" + } + }, + { + year: "2025", + month: "12", + day: "31", + cents: 5500, + category: { + name: "Automotive:Gasoline" + }, + merchant: { + name: "Casey's" + }, + tags: [ + { + name: "Truck" + } + ] + } +]; + +console.log({ postFakeCategories, postFakeMerchants, postFakeTags, postFakeExpenses }); + + +// async function fetchData(url) { +// try { +// const response = await fetch(url); +// if (!response.ok) { +// throw new Error('Network response was not ok'); +// } +// const data = await response.json(); // Wait for the JSON data to be parsed +// console.log(data); +// } catch (error) { +// console.error('Error:', error); +// } +// } + +// // Call the async function +// fetchData('https://api.example.com/data'); diff --git a/package-lock.json b/package-lock.json index 129eef6..ae2ff50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,17 +9,19 @@ "version": "0.0.1", "license": "UNLICENSED", "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", "@nestjs/typeorm": "^11.0.0", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sqlite3": "^5.1.7", - "typeorm": "^0.3.28" + "sqlite3": "^5.0.2", + "typeorm": "^0.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -2062,6 +2064,18 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@js-temporal/polyfill": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/@js-temporal/polyfill/-/polyfill-0.5.1.tgz", + "integrity": "sha512-hloP58zRVCRSpgDxmqCWJNlizAlUgJFqG2ypq79DCvyv9tHjRYMDOcPFjzfl/A1/YxDvRCZz8wvZvmapQnKwFQ==", + "license": "ISC", + "dependencies": { + "jsbi": "^4.3.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/@lukeed/csprng": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@lukeed/csprng/-/csprng-1.1.0.tgz", @@ -2071,6 +2085,12 @@ "node": ">=8" } }, + "node_modules/@microsoft/tsdoc": { + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@microsoft/tsdoc/-/tsdoc-0.16.0.tgz", + "integrity": "sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==", + "license": "MIT" + }, "node_modules/@napi-rs/wasm-runtime": { "version": "0.2.12", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", @@ -2526,6 +2546,39 @@ "tslib": "^2.1.0" } }, + "node_modules/@nestjs/swagger": { + "version": "11.2.6", + "resolved": "https://registry.npmjs.org/@nestjs/swagger/-/swagger-11.2.6.tgz", + "integrity": "sha512-oiXOxMQqDFyv1AKAqFzSo6JPvMEs4uA36Eyz/s2aloZLxUjcLfUMELSLSNQunr61xCPTpwEOShfmO7NIufKXdA==", + "license": "MIT", + "dependencies": { + "@microsoft/tsdoc": "0.16.0", + "@nestjs/mapped-types": "2.1.0", + "js-yaml": "4.1.1", + "lodash": "4.17.23", + "path-to-regexp": "8.3.0", + "swagger-ui-dist": "5.31.0" + }, + "peerDependencies": { + "@fastify/static": "^8.0.0 || ^9.0.0", + "@nestjs/common": "^11.0.1", + "@nestjs/core": "^11.0.1", + "class-transformer": "*", + "class-validator": "*", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "@fastify/static": { + "optional": true + }, + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/testing": { "version": "11.1.13", "resolved": "https://registry.npmjs.org/@nestjs/testing/-/testing-11.1.13.tgz", @@ -2668,6 +2721,13 @@ "url": "https://opencollective.com/pkgr" } }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.34.48", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.34.48.tgz", @@ -4071,7 +4131,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/array-timsort": { @@ -7777,7 +7836,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -7786,6 +7844,12 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsbi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/jsbi/-/jsbi-4.3.2.tgz", + "integrity": "sha512-9fqMSQbhJykSeii05nxKl4m6Eqn2P6rOlYiS+C5Dr/HPIU/7yZxu5qzbs40tgaFORiw2Amd0mirjxatXYMkIew==", + "license": "Apache-2.0" + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -9475,9 +9539,9 @@ "license": "MIT" }, "node_modules/qs": { - "version": "6.14.1", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", - "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", + "version": "6.14.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz", + "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==", "license": "BSD-3-Clause", "dependencies": { "side-channel": "^1.1.0" @@ -10433,6 +10497,15 @@ "node": ">=8" } }, + "node_modules/swagger-ui-dist": { + "version": "5.31.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.31.0.tgz", + "integrity": "sha512-zSUTIck02fSga6rc0RZP3b7J7wgHXwLea8ZjgLA3Vgnb8QeOl3Wou2/j5QkzSGeoz6HusP/coYuJl33aQxQZpg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, "node_modules/symbol-observable": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/symbol-observable/-/symbol-observable-4.0.0.tgz", diff --git a/package.json b/package.json index 448c9c7..9237d8a 100644 --- a/package.json +++ b/package.json @@ -20,17 +20,19 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@js-temporal/polyfill": "^0.5.1", "@nestjs/common": "^11.0.1", "@nestjs/config": "^4.0.3", "@nestjs/core": "^11.0.1", "@nestjs/mapped-types": "*", "@nestjs/platform-express": "^11.0.1", + "@nestjs/swagger": "^11.2.6", "@nestjs/typeorm": "^11.0.0", "pg": "^8.18.0", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "sqlite3": "^5.1.7", - "typeorm": "^0.3.28" + "sqlite3": "^5.0.2", + "typeorm": "^0.3.6" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/src/app.module.ts b/src/app.module.ts index 20cde09..ff6780e 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,10 +1,9 @@ import { Module } from '@nestjs/common'; -import { AppController } from './app.controller'; +import { HealthcheckController } from './healthcheck.controller'; import { TypeOrmModule } from '@nestjs/typeorm'; import { MerchantsModule } from './merchants/merchants.module'; import { TagsModule } from './tags/tags.module'; import { CategoriesModule } from './categories/categories.module'; -import { SubCategoriesModule } from './sub-categories/sub-categories.module'; import { ExpensesModule } from './expenses/expenses.module'; import { ConfigModule, ConfigService } from '@nestjs/config'; @@ -37,9 +36,8 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; MerchantsModule, TagsModule, CategoriesModule, - SubCategoriesModule, ExpensesModule ], - controllers: [AppController] + controllers: [HealthcheckController] }) export class AppModule { } diff --git a/src/categories/dto/create-category.dto.ts b/src/categories/dto/create-category.dto.ts index 2222672..a25029d 100644 --- a/src/categories/dto/create-category.dto.ts +++ b/src/categories/dto/create-category.dto.ts @@ -1,3 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + export class CreateCategoryDto { + @ApiProperty({ + description: 'Category for expenses' + }) name: string; } diff --git a/src/categories/dto/update-category.dto.ts b/src/categories/dto/update-category.dto.ts index 6657a69..d15fbbc 100644 --- a/src/categories/dto/update-category.dto.ts +++ b/src/categories/dto/update-category.dto.ts @@ -1,4 +1,9 @@ -export class UpdateCategoryDto { +import { ApiProperty } from '@nestjs/swagger'; +import { CreateCategoryDto } from './create-category.dto'; + +export class UpdateCategoryDto extends CreateCategoryDto { + @ApiProperty({ + description: 'Unique ID of the category' + }) id: string; - name: string; } diff --git a/src/expenses/dto/create-expense.dto.ts b/src/expenses/dto/create-expense.dto.ts index 52a8f38..829a5be 100644 --- a/src/expenses/dto/create-expense.dto.ts +++ b/src/expenses/dto/create-expense.dto.ts @@ -1,10 +1,35 @@ -import { Category } from '../../categories/entities/category.entity'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Temporal } from '@js-temporal/polyfill'; export class CreateExpenseDto { - year: string; - month: string; - day: string; + @ApiProperty({ + description: 'Date in YYYY-MM-DD format' + }) + date: Temporal.PlainDate; + + @ApiProperty({ + description: 'Amount of expense in cents' + }) cents: number; - description: string; - category: Category + + @ApiProperty({ + description: 'Category ID of expense' + }) + categoryId: string; + + @ApiPropertyOptional({ + description: 'Optional note about expense' + }) + note?: string; + + @ApiPropertyOptional({ + description: 'Optional merchant ID for the expense' + }) + merchantId?: string; + + @ApiPropertyOptional({ + type: [String], + description: 'Optional list of tag IDs for the expense' + }) + tagIds: string[]; } diff --git a/src/expenses/dto/get-expense.dto.ts b/src/expenses/dto/get-expense.dto.ts new file mode 100644 index 0000000..0e9454e --- /dev/null +++ b/src/expenses/dto/get-expense.dto.ts @@ -0,0 +1,43 @@ +import { Category } from '../../categories/entities/category.entity'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Merchant } from '../../merchants/entities/merchant.entity'; +import { Tag } from '../../tags/entities/tag.entity'; +import { Temporal } from '@js-temporal/polyfill'; + +export class GetExpenseDto { + @ApiProperty({ + description: 'Unique ID of expense' + }) + id: string; + + @ApiProperty({ + description: 'Date in YYYY-MM-DD format' + }) + date: Temporal.PlainDate; + + @ApiProperty({ + description: 'Amount of expense in cents' + }) + cents: number; + + @ApiProperty({ + description: 'Category of expense' + }) + category: Category + + @ApiPropertyOptional({ + description: 'Note about expense' + }) + note?: string; + + @ApiPropertyOptional({ + description: 'Merchant for the expense' + }) + merchant?: Merchant; + + @ApiPropertyOptional({ + type: [Tag], + description: 'List of tags for the expense' + }) + tags?: Tag[]; +} diff --git a/src/expenses/dto/update-expense.dto.ts b/src/expenses/dto/update-expense.dto.ts index e5be379..2ce99d9 100644 --- a/src/expenses/dto/update-expense.dto.ts +++ b/src/expenses/dto/update-expense.dto.ts @@ -1,6 +1,9 @@ -import { PartialType } from '@nestjs/mapped-types'; import { CreateExpenseDto } from './create-expense.dto'; +import { ApiProperty } from '@nestjs/swagger'; -export class UpdateExpenseDto extends PartialType(CreateExpenseDto) { +export class UpdateExpenseDto extends CreateExpenseDto { + @ApiProperty({ + description: 'Unique ID of the expense' + }) id: string; } diff --git a/src/expenses/entities/expense.entity.ts b/src/expenses/entities/expense.entity.ts index 36a116d..75b7cab 100644 --- a/src/expenses/entities/expense.entity.ts +++ b/src/expenses/entities/expense.entity.ts @@ -1,6 +1,5 @@ import { Column, Entity, JoinTable, ManyToMany, ManyToOne, PrimaryGeneratedColumn } from 'typeorm'; import { Tag } from '../../tags/entities/tag.entity'; -import { SubCategory } from '../../sub-categories/entities/sub-category.entity'; import { Category } from '../../categories/entities/category.entity'; import { Merchant } from '../../merchants/entities/merchant.entity'; @@ -10,25 +9,16 @@ export class Expense { id: string; @Column() - year: string; - - @Column() - month: string; - - @Column() - day: string; + date: string; @Column() cents: number; - @Column({ nullable: true }) - description: string; - @ManyToOne(() => Category, { eager: true }) category: Category; - @ManyToOne(() => SubCategory, { nullable: true, eager: true }) - subCategory: SubCategory; + @Column({ nullable: true }) + note: string; @ManyToOne(() => Merchant, { nullable: true, eager: true }) merchant: Merchant; diff --git a/src/expenses/expense-data.service.ts b/src/expenses/expense-data.service.ts index ceff180..ba960e5 100644 --- a/src/expenses/expense-data.service.ts +++ b/src/expenses/expense-data.service.ts @@ -1,8 +1,6 @@ import { Injectable } from '@nestjs/common'; import { DataSource, Repository } from 'typeorm'; import { Expense } from './entities/expense.entity'; -import { UpdateExpenseDto } from './dto/update-expense.dto'; -import { CreateExpenseDto } from './dto/create-expense.dto'; @Injectable() export class ExpenseDataService { @@ -20,15 +18,36 @@ export class ExpenseDataService { return await this.expenses.findOneBy({ id }); } - public async create(expense: CreateExpenseDto): Promise { - return await this.expenses.save(expense); + public async create(expense: CreateExpense): Promise { + const created = await this.expenses.save(expense); + return await this.expenses.findOneBy({ id: created.id }) as Expense; } - public async update(expense: UpdateExpenseDto): Promise { - return await this.expenses.save(expense); + public async update(expense: UpdateExpense): Promise { + await this.expenses.save(expense); + return await this.expenses.findOneBy({ id: expense.id }) as Expense; } public async delete(id: string): Promise { await this.expenses.delete({ id }); } } + +export interface CreateExpense { + date: string; + cents: number; + category: { + id: string; + }; + note?: string; + merchant?: { + id: string; + }; + tags?: { + id: string; + }[]; +} + +export interface UpdateExpense extends CreateExpense{ + id: string; +} diff --git a/src/expenses/expenses.controller.ts b/src/expenses/expenses.controller.ts index 98350b9..7550273 100644 --- a/src/expenses/expenses.controller.ts +++ b/src/expenses/expenses.controller.ts @@ -15,7 +15,7 @@ import { import { ExpensesService } from './expenses.service'; import { CreateExpenseDto } from './dto/create-expense.dto'; import { UpdateExpenseDto } from './dto/update-expense.dto'; -import { Expense } from './entities/expense.entity'; +import { GetExpenseDto } from './dto/get-expense.dto'; @Controller('expenses') export class ExpensesController { @@ -23,13 +23,13 @@ export class ExpensesController { @Get() @HttpCode(HttpStatus.OK) - public async findAll(): Promise { + public async findAll(): Promise { return await this.expensesService.findAll(); } @Get(':id') @HttpCode(HttpStatus.OK) - public async findOne(@Param('id') id: string): Promise { + public async findOne(@Param('id') id: string): Promise { if (!id) { throw new BadRequestException('No ID provided.'); } @@ -44,7 +44,7 @@ export class ExpensesController { @Post() @HttpCode(HttpStatus.CREATED) - public async create(@Body() expense: CreateExpenseDto): Promise { + public async create(@Body() expense: CreateExpenseDto): Promise { if (!expense) { throw new BadRequestException('Expense name cannot be empty.'); } @@ -59,7 +59,7 @@ export class ExpensesController { @Put() @HttpCode(HttpStatus.OK) - public async update(@Body() expense: UpdateExpenseDto): Promise { + public async update(@Body() expense: UpdateExpenseDto): Promise { if (!expense.id) { throw new BadRequestException('Expense ID cannot be empty.'); } diff --git a/src/expenses/expenses.service.ts b/src/expenses/expenses.service.ts index 4e822e6..c419e00 100644 --- a/src/expenses/expenses.service.ts +++ b/src/expenses/expenses.service.ts @@ -2,31 +2,68 @@ import { Injectable } from '@nestjs/common'; import { CreateExpenseDto } from './dto/create-expense.dto'; import { UpdateExpenseDto } from './dto/update-expense.dto'; import { ExpenseDataService } from './expense-data.service'; -import { Expense } from './entities/expense.entity'; +import { GetExpenseDto } from './dto/get-expense.dto'; +import { Temporal } from '@js-temporal/polyfill'; @Injectable() export class ExpensesService { public constructor(private expenseDataService: ExpenseDataService) { } - public async findAll(): Promise { - return await this.expenseDataService.getAll(); + public async findAll(): Promise { + const expenses = await this.expenseDataService.getAll(); + + return expenses.map(exp => { + return { ...exp, date: Temporal.PlainDate.from(exp.date) }; + }) } - public async findById(id: string): Promise { + public async findById(id: string): Promise { const expense = await this.expenseDataService.getById(id); if (!expense) { throw new Error('No expense found'); } - return expense; + return { ...expense, date: Temporal.PlainDate.from(expense.date) }; } - public async create(expense: CreateExpenseDto): Promise { - return await this.expenseDataService.create(expense); + public async create(createExpense: CreateExpenseDto): Promise { + const date = createExpense.date.toString(); + const category = { id: createExpense.categoryId }; + const merchant = createExpense.merchantId ? { id: createExpense.merchantId } : undefined; + const tags = createExpense.tagIds?.map(id => { + return { id }; + }) + + const expense = await this.expenseDataService.create({ + date, + cents: createExpense.cents, + note: createExpense.note, + category, + merchant, + tags + }); + + return { ...expense, date: Temporal.PlainDate.from(expense.date) }; } - public async update(expense: UpdateExpenseDto): Promise { - return await this.expenseDataService.update(expense); + public async update(updateExpense: UpdateExpenseDto): Promise { + const date = updateExpense.date.toString(); + const category = { id: updateExpense.categoryId }; + const merchant = updateExpense.merchantId ? { id: updateExpense.merchantId } : undefined; + const tags = updateExpense.tagIds?.map(id => { + return { id }; + }) + const expense = await this.expenseDataService.update({ + id: updateExpense.id, + date, + cents: updateExpense.cents, + note: updateExpense.note, + category, + merchant, + tags + }); + + return { ...expense, date: Temporal.PlainDate.from(expense.date) }; } public async remove(id: string): Promise { diff --git a/src/app.controller.ts b/src/healthcheck.controller.ts similarity index 79% rename from src/app.controller.ts rename to src/healthcheck.controller.ts index 0f03a45..bca0907 100644 --- a/src/app.controller.ts +++ b/src/healthcheck.controller.ts @@ -1,7 +1,7 @@ import { Controller, Get } from '@nestjs/common'; @Controller() -export class AppController { +export class HealthcheckController { @Get('healthcheck') health() { return { status: 'healthy' }; diff --git a/src/main.ts b/src/main.ts index d104832..2843802 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,20 @@ import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { NestExpressApplication } from '@nestjs/platform-express'; +import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; async function bootstrap(): Promise { const app = await NestFactory.create(AppModule); - app.setGlobalPrefix(process.env.PREFIX ?? 'common-cents'); + const appPrefix = process.env.PREFIX ?? 'common-cents'; + const config = new DocumentBuilder() + .setTitle('Common Cents API') + .setDescription('Documentation for Common Cents API') + .setVersion('1.0') + .build(); + const documentFactory = () => SwaggerModule.createDocument(app, config); + SwaggerModule.setup(appPrefix + '/docs', app, documentFactory); + app.enableCors(); // TODO: Research if this is worth worrying about + app.setGlobalPrefix(appPrefix); await app.listen(process.env.PORT ?? 3000); } void bootstrap(); diff --git a/src/merchants/dto/create-merchant.dto.ts b/src/merchants/dto/create-merchant.dto.ts index 3d1c09d..05fe980 100644 --- a/src/merchants/dto/create-merchant.dto.ts +++ b/src/merchants/dto/create-merchant.dto.ts @@ -1,3 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + export class CreateMerchantDto { + @ApiProperty({ + description: 'Merchant name' + }) name: string; } diff --git a/src/merchants/dto/update-merchant.dto.ts b/src/merchants/dto/update-merchant.dto.ts index b02185f..f9da3d5 100644 --- a/src/merchants/dto/update-merchant.dto.ts +++ b/src/merchants/dto/update-merchant.dto.ts @@ -1,4 +1,9 @@ -export class UpdateMerchantDto { +import { CreateMerchantDto } from './create-merchant.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateMerchantDto extends CreateMerchantDto { + @ApiProperty({ + description: 'Unique ID of merchant' + }) id: string; - name: string; } diff --git a/src/sub-categories/dto/create-sub-category.dto.ts b/src/sub-categories/dto/create-sub-category.dto.ts deleted file mode 100644 index 3837548..0000000 --- a/src/sub-categories/dto/create-sub-category.dto.ts +++ /dev/null @@ -1,3 +0,0 @@ -export class CreateSubCategoryDto { - name: string; -} diff --git a/src/sub-categories/dto/update-sub-category.dto.ts b/src/sub-categories/dto/update-sub-category.dto.ts deleted file mode 100644 index 0541922..0000000 --- a/src/sub-categories/dto/update-sub-category.dto.ts +++ /dev/null @@ -1,4 +0,0 @@ -export class UpdateSubCategoryDto { - id: string; - name: string; -} diff --git a/src/sub-categories/entities/sub-category.entity.ts b/src/sub-categories/entities/sub-category.entity.ts deleted file mode 100644 index f6bf282..0000000 --- a/src/sub-categories/entities/sub-category.entity.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; - -@Entity() -export class SubCategory { - @PrimaryGeneratedColumn('uuid') - id: string; - - @Column({ unique: true }) - name: string; -} diff --git a/src/sub-categories/sub-categories.controller.ts b/src/sub-categories/sub-categories.controller.ts deleted file mode 100644 index 9277087..0000000 --- a/src/sub-categories/sub-categories.controller.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - Controller, - Get, - Post, - Put, - Delete, - Body, - Param, - HttpCode, - HttpStatus, - BadRequestException, - NotFoundException, - InternalServerErrorException -} from '@nestjs/common'; -import { SubCategoriesService } from './sub-categories.service'; -import { CreateSubCategoryDto } from './dto/create-sub-category.dto'; -import { UpdateSubCategoryDto } from './dto/update-sub-category.dto'; -import { SubCategory } from './entities/sub-category.entity'; - -@Controller('sub-categories') -export class SubCategoriesController { - constructor(private readonly subCategoriesService: SubCategoriesService) { } - - @Get() - @HttpCode(HttpStatus.OK) - public async findAll(): Promise { - return await this.subCategoriesService.findAll(); - } - - @Get(':id') - @HttpCode(HttpStatus.OK) - public async findOne(@Param('id') id: string): Promise { - if (!id) { - throw new BadRequestException('No ID provided.'); - } - - try { - return await this.subCategoriesService.findById(id); - } - catch (error) { - throw new NotFoundException(error); - } - } - - @Post() - @HttpCode(HttpStatus.CREATED) - public async create(@Body() subCategory: CreateSubCategoryDto): Promise { - if (!subCategory.name) { - throw new BadRequestException('Sub-category name cannot be empty.'); - } - - try { - return await this.subCategoriesService.create(subCategory); - } - catch (error) { - throw new InternalServerErrorException(error); - } - } - - @Put() - @HttpCode(HttpStatus.OK) - public async update(@Body() subCategory: UpdateSubCategoryDto): Promise { - if (!subCategory.id) { - throw new BadRequestException('Sub-category ID cannot be empty.'); - } - - return await this.subCategoriesService.update(subCategory); - } - - @Delete(':id') - @HttpCode(HttpStatus.NO_CONTENT) - public async remove(@Param('id') id: string): Promise { - return await this.subCategoriesService.remove(id); - } -} diff --git a/src/sub-categories/sub-categories.module.ts b/src/sub-categories/sub-categories.module.ts deleted file mode 100644 index 7374dec..0000000 --- a/src/sub-categories/sub-categories.module.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { Module } from '@nestjs/common'; -import { SubCategoriesService } from './sub-categories.service'; -import { SubCategoriesController } from './sub-categories.controller'; -import { SubCategoryDataService } from './sub-category-data.service'; -import { TypeOrmModule } from '@nestjs/typeorm'; -import { SubCategory } from './entities/sub-category.entity'; - -@Module({ - controllers: [SubCategoriesController], - providers: [SubCategoriesService, SubCategoryDataService], - imports: [TypeOrmModule.forFeature([SubCategory])], - exports: [TypeOrmModule] -}) -export class SubCategoriesModule { } diff --git a/src/sub-categories/sub-categories.service.ts b/src/sub-categories/sub-categories.service.ts deleted file mode 100644 index 15c35b1..0000000 --- a/src/sub-categories/sub-categories.service.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { CreateSubCategoryDto } from './dto/create-sub-category.dto'; -import { UpdateSubCategoryDto } from './dto/update-sub-category.dto'; -import { SubCategoryDataService } from './sub-category-data.service'; -import { SubCategory } from './entities/sub-category.entity'; - -@Injectable() -export class SubCategoriesService { - public constructor(private subCategoryDataService: SubCategoryDataService) { } - - public async findAll(): Promise { - return await this.subCategoryDataService.getAll(); - } - - public async findById(id: string): Promise { - const subCategory = await this.subCategoryDataService.getById(id); - if (!subCategory) { - throw new Error('No sub-category found'); - } - - return subCategory; - } - - public async create(subCategory: CreateSubCategoryDto): Promise { - return await this.subCategoryDataService.create(subCategory.name); - } - - public async update(subCategory: UpdateSubCategoryDto): Promise { - return await this.subCategoryDataService.update(subCategory); - } - - public async remove(id: string): Promise { - await this.subCategoryDataService.delete(id); - } -} diff --git a/src/sub-categories/sub-category-data.service.ts b/src/sub-categories/sub-category-data.service.ts deleted file mode 100644 index 3d57e66..0000000 --- a/src/sub-categories/sub-category-data.service.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { Injectable } from '@nestjs/common'; -import { DataSource, Repository } from 'typeorm'; -import { SubCategory } from './entities/sub-category.entity'; -import { UpdateSubCategoryDto } from './dto/update-sub-category.dto'; - -@Injectable() -export class SubCategoryDataService { - private subCategories: Repository; - - public constructor(private dataSource: DataSource) { - this.subCategories = this.dataSource.getRepository(SubCategory); - } - - public async getAll(): Promise { - return await this.subCategories.find(); - } - - public async getById(id: string): Promise { - return await this.subCategories.findOneBy({ id }); - } - - public async create(name: string): Promise { - return await this.subCategories.save({ name }); - } - - public async update(subCategory: UpdateSubCategoryDto): Promise { - return await this.subCategories.save(subCategory); - } - - public async delete(id: string): Promise { - await this.subCategories.delete({ id }); - } -} diff --git a/src/tags/dto/create-tag.dto.ts b/src/tags/dto/create-tag.dto.ts index 8235483..c0bddbc 100644 --- a/src/tags/dto/create-tag.dto.ts +++ b/src/tags/dto/create-tag.dto.ts @@ -1,3 +1,8 @@ +import { ApiProperty } from '@nestjs/swagger'; + export class CreateTagDto { + @ApiProperty({ + description: 'Tag for expenses' + }) name: string; } diff --git a/src/tags/dto/update-tag.dto.ts b/src/tags/dto/update-tag.dto.ts index ab32e5f..51da6b6 100644 --- a/src/tags/dto/update-tag.dto.ts +++ b/src/tags/dto/update-tag.dto.ts @@ -1,4 +1,9 @@ -export class UpdateTagDto { +import { CreateTagDto } from './create-tag.dto'; +import { ApiProperty } from '@nestjs/swagger'; + +export class UpdateTagDto extends CreateTagDto { + @ApiProperty({ + description: 'Unique ID of tag' + }) id: string; - name: string; } diff --git a/test/app.e2e-spec.ts b/test/app.e2e-spec.ts index 36852c5..593635a 100644 --- a/test/app.e2e-spec.ts +++ b/test/app.e2e-spec.ts @@ -4,7 +4,7 @@ import request from 'supertest'; import { App } from 'supertest/types'; import { AppModule } from './../src/app.module'; -describe('AppController (e2e)', () => { +describe('HealthcheckController (e2e)', () => { let app: INestApplication; beforeEach(async () => {