diff --git a/apps/api/src/app/app.module.ts b/apps/api/src/app/app.module.ts index f3adeab..a504f95 100644 --- a/apps/api/src/app/app.module.ts +++ b/apps/api/src/app/app.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { AuthModule } from '@nx-next-nest-prisma-ory-template/auth'; import { DatabaseModule } from '@nx-next-nest-prisma-ory-template/database'; +import { NotesModule } from '@nx-next-nest-prisma-ory-template/notes'; import { OpenTelemetryModule } from '@nx-next-nest-prisma-ory-template/opentelemetry'; @Module({ @@ -10,6 +11,7 @@ import { OpenTelemetryModule } from '@nx-next-nest-prisma-ory-template/opentelem ConfigModule.forRoot(), AuthModule, DatabaseModule, + NotesModule, ], controllers: [], providers: [], diff --git a/packages/notes/.eslintrc.json b/packages/notes/.eslintrc.json new file mode 100644 index 0000000..9d9c0db --- /dev/null +++ b/packages/notes/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/packages/notes/jest.config.ts b/packages/notes/jest.config.ts new file mode 100644 index 0000000..dceee13 --- /dev/null +++ b/packages/notes/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'notes', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/packages/notes', +}; diff --git a/packages/notes/project.json b/packages/notes/project.json new file mode 100644 index 0000000..c9057a2 --- /dev/null +++ b/packages/notes/project.json @@ -0,0 +1,23 @@ +{ + "name": "notes", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "packages/notes/src", + "projectType": "library", + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["packages/notes/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "packages/notes/jest.config.ts" + } + } + }, + "tags": [] +} diff --git a/packages/notes/src/index.ts b/packages/notes/src/index.ts new file mode 100644 index 0000000..83b8c2c --- /dev/null +++ b/packages/notes/src/index.ts @@ -0,0 +1 @@ +export * from './lib/notes.module'; diff --git a/packages/notes/src/lib/notes.controller.ts b/packages/notes/src/lib/notes.controller.ts new file mode 100644 index 0000000..3ddc9a2 --- /dev/null +++ b/packages/notes/src/lib/notes.controller.ts @@ -0,0 +1,159 @@ +import { + Body, + Controller, + NotFoundException, + Param, + Req, + RequestMethod, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { ApiRouteAuthenticated } from '@nx-next-nest-prisma-ory-template/utils'; +import { NotesService } from './notes.service'; +import { FastifyRequest } from 'fastify'; +import { + CreateNoteDto, + Note, + NoteDto, + UpdateNoteDto, +} from '@nx-next-nest-prisma-ory-template/types'; + +@ApiTags('Notes') +@Controller('notes') +export class NotesController { + constructor(private notesService: NotesService) {} + + @ApiRouteAuthenticated({ + method: RequestMethod.POST, + operation: { + summary: 'Create a note', + description: 'Create a new note with the given name and type', + }, + response: { + status: 201, + type: NoteDto, + }, + }) + create( + @Body() body: CreateNoteDto, + @Req() request: FastifyRequest + ): Promise { + return this.notesService.create(body, request.user.sub); + } + + @ApiRouteAuthenticated({ + method: RequestMethod.GET, + operation: { + summary: 'List all notes', + description: 'Returns the list of all notes (limited to 1000)', + }, + response: { + status: 200, + schema: { + type: 'array', + items: { + type: 'object', + properties: { + id: { + type: 'string', + description: 'The note id', + example: 'b1774d2f-05f7-4ea4-b427-0d808bdca583', + }, + title: { + type: 'string', + description: 'The note title', + example: 'My note', + }, + body: { + type: 'string', + description: 'The note body', + example: 'This is the body of my note', + }, + createdAt: { + type: 'string', + description: 'The note creation date', + example: '2021-03-11T20:43:06.000Z', + }, + }, + }, + }, + }, + }) + async list(@Req() request: FastifyRequest): Promise { + const notes = await this.notesService.list(request.user.sub); + + if (!notes) { + throw new NotFoundException(); + } + + return notes; + } + + @ApiRouteAuthenticated({ + method: RequestMethod.GET, + path: ':noteId', + operation: { + summary: 'Get a specific note', + description: 'Returns the note with the given id', + }, + response: { + status: 200, + type: NoteDto, + }, + }) + async get(@Param('noteId') noteId: string): Promise { + const note = await this.notesService.get(noteId); + + if (!note) { + throw new NotFoundException(); + } + + return note; + } + + @ApiRouteAuthenticated({ + method: RequestMethod.PATCH, + path: ':noteId', + operation: { + summary: 'Update a specific note', + description: 'Updates the note with the given id', + }, + response: { + status: 200, + type: NoteDto, + }, + }) + async patch( + @Body() body: UpdateNoteDto, + @Param('noteId') noteId: string + ): Promise { + const note = await this.notesService.patch(noteId, body); + + if (!note) { + throw new NotFoundException(); + } + + return note; + } + + @ApiRouteAuthenticated({ + method: RequestMethod.DELETE, + path: ':noteId', + operation: { + summary: 'Delete a specific note', + description: 'Deletes the note with the given id', + }, + response: { + status: 200, + type: NoteDto, + }, + }) + async delete(@Param('noteId') noteId: string): Promise { + const note = await this.notesService.delete(noteId); + + if (!note) { + throw new NotFoundException(); + } + + return note; + } +} diff --git a/packages/notes/src/lib/notes.module.ts b/packages/notes/src/lib/notes.module.ts new file mode 100644 index 0000000..b26783a --- /dev/null +++ b/packages/notes/src/lib/notes.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { AuthModule } from '@nx-next-nest-prisma-ory-template/auth'; +import { DatabaseModule } from '@nx-next-nest-prisma-ory-template/database'; +import { NotesController } from './notes.controller'; +import { NotesService } from './notes.service'; + +@Module({ + imports: [AuthModule, DatabaseModule], + controllers: [NotesController], + providers: [NotesService], + exports: [], +}) +export class NotesModule {} diff --git a/packages/notes/src/lib/notes.service.ts b/packages/notes/src/lib/notes.service.ts new file mode 100644 index 0000000..4fb6a08 --- /dev/null +++ b/packages/notes/src/lib/notes.service.ts @@ -0,0 +1,196 @@ +import { Injectable } from '@nestjs/common'; +import { + Transaction, + TransactionStep, +} from '@nx-next-nest-prisma-ory-template/utils'; +import { KetoService } from '@nx-next-nest-prisma-ory-template/auth'; +import { PrismaService } from '@nx-next-nest-prisma-ory-template/database'; +import { + CreateNoteDto, + Note, + UpdateNoteDto, +} from '@nx-next-nest-prisma-ory-template/types'; + +@Injectable() +export class NotesService { + constructor(private prisma: PrismaService, private keto: KetoService) {} + + /** + * The `create` function creates a new note and associates it with a specified subject using a + * transaction. + * @param {CreateNoteDto} dto - The `dto` parameter is an object of type `CreateNoteDto` which contains + * the data needed to create a new note. It likely includes properties such as `title` and `body` which + * represent the title and content of the note. + * @param {string} subject - The `subject` parameter is a string that represents the owner or creator + * of the note. It is used to create a relation between the note and the owner in the `keto` service. + * @returns a Promise that resolves to a Note object. + */ + async create(dto: CreateNoteDto, subject: string): Promise { + const transaction = new Transaction(); + + let note: Note | null = null; + + transaction.addStep( + new TransactionStep( + async () => { + note = await this.prisma.note.create({ + data: { + title: dto.title, + body: dto.body, + }, + }); + }, + async () => { + if (note) { + await this.prisma.note.delete({ + where: { + id: note.id, + }, + }); + } + } + ) + ); + + transaction.addStep( + new TransactionStep( + async () => { + await this.keto.createRelation({ + namespace: 'notes', + object: note?.id, + relation: 'owner', + subject_id: subject, + }); + }, + () => null + ) + ); + + await transaction.commit(); + + if (!note) { + throw new Error('Failed to create project'); + } + + return note; + } + + /** + * The `list` function retrieves a list of notes associated with a specific subject + * @param {string} subject - A string representing the unique identifier of the subject that you want + * to retrieve notes for. + * @returns The `list` function is returning a `Promise` that resolves to an array of `Note` objects. + */ + async list(subject: string): Promise { + const relationships = await this.keto.getRelationships({ + pageSize: 1000, + namespace: 'notes', + relation: 'owner', + subjectId: subject, + }); + + if (!relationships.relation_tuples) { + return []; + } + + return this.prisma.note.findMany({ + where: { + id: { + in: relationships.relation_tuples?.map((r) => r.object), + }, + }, + }); + } + + /** + * The `get` function retrieves a note with a specific ID + * @param {string} id - A string representing the unique identifier of the note that you want to + * retrieve. + * @returns The `get` function is returning a `Promise` that resolves to either a `Note` object or + * `null`. + */ + get(id: string): Promise { + return this.prisma.note.findUnique({ + where: { + id, + }, + }); + } + + /** + * The `update` function updates a note with a specific ID + * @param {string} noteId - A string representing the unique identifier of the note that you want to + * update. + * @param {UpdateNoteDto} dto - The `dto` parameter is an object of type `UpdateNoteDto` which contains + * the data needed to update a note. It likely includes properties such as `title` and `body` which + * represent the title and content of the note. + * @returns The `update` function is returning a `Promise` that resolves to a `Note` object. + */ + patch(noteId: string, dto: UpdateNoteDto): Promise { + return this.prisma.note.update({ + where: { + id: noteId, + }, + data: { + title: dto.title, + body: dto.body, + }, + }); + } + + /** + * The `delete` function deletes a note with a specific ID + * @param {string} noteId - A string representing the unique identifier of the note that you want to + * delete. + * @returns The `delete` function is returning a `Promise` that resolves to a `Note` object. + */ + async delete(noteId: string): Promise { + const transaction = new Transaction(); + + let note: Note | null = null; + + transaction.addStep( + new TransactionStep( + async () => { + note = await this.prisma.note.delete({ + where: { + id: noteId, + }, + }); + }, + async () => { + if (note) { + await this.prisma.note.create({ + data: { + id: note.id, + title: note.title, + body: note.body, + createdAt: note.createdAt, + }, + }); + } + } + ) + ); + + transaction.addStep( + new TransactionStep( + async () => { + await this.keto.deleteRelation({ + namespace: 'notes', + object: noteId, + }); + }, + () => null + ) + ); + + await transaction.commit(); + + if (!note) { + throw new Error('Failed to create note'); + } + + return note; + } +} diff --git a/packages/notes/tsconfig.json b/packages/notes/tsconfig.json new file mode 100644 index 0000000..f5b8565 --- /dev/null +++ b/packages/notes/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/packages/notes/tsconfig.lib.json b/packages/notes/tsconfig.lib.json new file mode 100644 index 0000000..c297a24 --- /dev/null +++ b/packages/notes/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/packages/notes/tsconfig.spec.json b/packages/notes/tsconfig.spec.json new file mode 100644 index 0000000..9b2a121 --- /dev/null +++ b/packages/notes/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/tsconfig.base.json b/tsconfig.base.json index 05031f9..55abf9b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -25,6 +25,9 @@ "@nx-next-nest-prisma-ory-template/error": [ "packages/error/src/index.ts" ], + "@nx-next-nest-prisma-ory-template/notes": [ + "packages/notes/src/index.ts" + ], "@nx-next-nest-prisma-ory-template/opentelemetry": [ "packages/opentelemetry/src/index.ts" ],