diff --git a/docs/dev/db-schema.plantuml b/docs/dev/db-schema.plantuml index 130bcb63f..56063af26 100644 --- a/docs/dev/db-schema.plantuml +++ b/docs/dev/db-schema.plantuml @@ -58,7 +58,7 @@ entity "Session" as seesion { entity "Revision" { - *id : uuid <> + *id : number <> -- *noteId : uuid <> *content : text @@ -78,7 +78,7 @@ entity "Authorship" { } entity "RevisionAuthorship" { - *revisionId : uuid <> + *revisionId : number <> *authorshipId : uuid <> } @@ -115,11 +115,11 @@ entity "Group" { *canEdit : boolean } -Note "1" -- "1..*" Revision -Revision "0..*" -- "0..*" Authorship +Note "1" - "1..*" Revision +Revision "0..*" - "0..*" Authorship (Revision, Authorship) .. RevisionAuthorship Authorship "0..*" -- "1" User -Note "1" -- "0..*" User : owner +Note "0..*" -- "1" User : owner Note "1" -- "0..*" NoteUserPermission NoteUserPermission "1" -- "1" User Note "1" -- "0..*" NoteGroupPermission diff --git a/docs/dev/public_api.yml b/docs/dev/public_api.yml index 0fc6813bc..f7314686c 100644 --- a/docs/dev/public_api.yml +++ b/docs/dev/public_api.yml @@ -368,7 +368,7 @@ paths: - note summary: Returns a list of the available note revisions operationId: getAllRevisionsOfNote - description: The list is returned as a JSON object with an array of revision-id and length associations. The revision-id equals to the timestamp when the revision was saved. + description: The list contains the revision-id, the length and a ISO-timestamp of the creation date. responses: '200': description: Revisions of the note. @@ -399,7 +399,7 @@ paths: description: The revision is returned as a JSON object with the content of the note and the authorship. responses: '200': - description: Revision of the note for the given timestamp. + description: Revision of the note for the given id. content: application/json: schema: @@ -421,7 +421,7 @@ paths: - name: revision-id in: path required: true - description: The id (timestamp) of the revision to fetch. + description: The id of the revision to fetch. content: text/plain: example: 1570921051959 @@ -579,7 +579,7 @@ components: description: A tag updateTime: type: integer - description: UNIX-timestamp of when the note was last changed. + description: ISO-timestamp of when the note was last changed. updateUser: $ref: "#/components/schemas/UserInfo" viewCount: @@ -588,7 +588,7 @@ components: description: How often the published version of the note was viewed. createTime: type: string - description: The timestamp when the note was created in ISO 8601 format. + description: The ISO-timestamp when the note was created in ISO 8601 format. editedBy: type: array description: List of usernames who edited the note. @@ -614,20 +614,19 @@ components: type: boolean NoteRevisionsMetadata: - type: object - properties: - revision: - type: array - description: Array that holds all revision-info objects. - items: - type: object - properties: - time: - type: integer - description: UNIX-timestamp of when the revision was saved. Is also the revision-id. - length: - type: integer - description: Length of the document to the timepoint the revision was saved. + type: array + items: + type: object + properties: + id: + type: integer + description: The id of the revision + createdAt: + type: integer + description: ISO-timestamp of when the revision was saved. Is also the revision-id. + length: + type: integer + description: Length of the document to the timepoint the revision was saved. NoteRevision: type: object properties: diff --git a/package.json b/package.json index 41bb7c760..8f7006537 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "class-transformer": "^0.2.3", "class-validator": "^0.12.2", "connect-typeorm": "^1.1.4", + "raw-body": "^2.4.1", "reflect-metadata": "^0.1.13", "rimraf": "^3.0.2", "rxjs": "^6.5.4", diff --git a/src/api/public/me/me.controller.spec.ts b/src/api/public/me/me.controller.spec.ts index 66a5b33ed..1aef2f688 100644 --- a/src/api/public/me/me.controller.spec.ts +++ b/src/api/public/me/me.controller.spec.ts @@ -4,6 +4,8 @@ import { HistoryModule } from '../../../history/history.module'; import { AuthorColor } from '../../../notes/author-color.entity'; import { Note } from '../../../notes/note.entity'; import { NotesModule } from '../../../notes/notes.module'; +import { Authorship } from '../../../revisions/authorship.entity'; +import { Revision } from '../../../revisions/revision.entity'; import { AuthToken } from '../../../users/auth-token.entity'; import { Identity } from '../../../users/identity.entity'; import { User } from '../../../users/user.entity'; @@ -28,6 +30,10 @@ describe('Me Controller', () => { .useValue({}) .overrideProvider(getRepositoryToken(AuthorColor)) .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) .compile(); controller = module.get(MeController); diff --git a/src/api/public/notes/notes.controller.spec.ts b/src/api/public/notes/notes.controller.spec.ts index e6992c48d..863461266 100644 --- a/src/api/public/notes/notes.controller.spec.ts +++ b/src/api/public/notes/notes.controller.spec.ts @@ -1,10 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuthorColor } from '../../../notes/author-color.entity'; import { Note } from '../../../notes/note.entity'; import { NotesService } from '../../../notes/notes.service'; import { Authorship } from '../../../revisions/authorship.entity'; import { Revision } from '../../../revisions/revision.entity'; import { RevisionsModule } from '../../../revisions/revisions.module'; +import { AuthToken } from '../../../users/auth-token.entity'; +import { Identity } from '../../../users/identity.entity'; +import { User } from '../../../users/user.entity'; +import { UsersModule } from '../../../users/users.module'; import { NotesController } from './notes.controller'; describe('Notes Controller', () => { @@ -13,8 +18,14 @@ describe('Notes Controller', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ controllers: [NotesController], - providers: [NotesService], - imports: [RevisionsModule], + providers: [ + NotesService, + { + provide: getRepositoryToken(Note), + useValue: {}, + }, + ], + imports: [RevisionsModule, UsersModule], }) .overrideProvider(getRepositoryToken(Note)) .useValue({}) @@ -22,6 +33,16 @@ describe('Notes Controller', () => { .useValue({}) .overrideProvider(getRepositoryToken(Authorship)) .useValue({}) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) .compile(); controller = module.get(NotesController); diff --git a/src/api/public/notes/notes.controller.ts b/src/api/public/notes/notes.controller.ts index 7d64834ff..e0f15d551 100644 --- a/src/api/public/notes/notes.controller.ts +++ b/src/api/public/notes/notes.controller.ts @@ -1,53 +1,98 @@ import { + BadRequestException, Body, Controller, Delete, Get, Header, + Logger, Param, Post, Put, + Req, } from '@nestjs/common'; +import { Request } from 'express'; +import * as getRawBody from 'raw-body'; import { NotePermissionsUpdateDto } from '../../../notes/note-permissions.dto'; import { NotesService } from '../../../notes/notes.service'; import { RevisionsService } from '../../../revisions/revisions.service'; @Controller('notes') export class NotesController { + private readonly logger = new Logger(NotesController.name); + constructor( private noteService: NotesService, private revisionsService: RevisionsService, ) {} + /** + * Extract the raw markdown from the request body and create a new note with it + * + * Implementation inspired by https://stackoverflow.com/questions/52283713/how-do-i-pass-plain-text-as-my-request-body-using-nestjs + */ @Post() - createNote(@Body() noteContent: string) { - return this.noteService.createNote(noteContent); + async createNote(@Req() req: Request) { + // we have to check req.readable because of raw-body issue #57 + // https://github.com/stream-utils/raw-body/issues/57 + if (req.readable) { + let bodyText: string = await getRawBody(req, 'utf-8'); + bodyText = bodyText.trim(); + this.logger.debug('Got raw markdown:\n' + bodyText); + return this.noteService.createNoteDto(bodyText); + } else { + // TODO: Better error message + throw new BadRequestException('Invalid body'); + } } @Get(':noteIdOrAlias') getNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { - return this.noteService.getNoteByIdOrAlias(noteIdOrAlias); + return this.noteService.getNoteDtoByIdOrAlias(noteIdOrAlias); } @Post(':noteAlias') - createNamedNote( + async createNamedNote( @Param('noteAlias') noteAlias: string, - @Body() noteContent: string, + @Req() req: Request, ) { - return this.noteService.createNote(noteContent, noteAlias); + // we have to check req.readable because of raw-body issue #57 + // https://github.com/stream-utils/raw-body/issues/57 + if (req.readable) { + let bodyText: string = await getRawBody(req, 'utf-8'); + bodyText = bodyText.trim(); + this.logger.debug('Got raw markdown:\n' + bodyText); + return this.noteService.createNoteDto(bodyText, noteAlias); + } else { + // TODO: Better error message + throw new BadRequestException('Invalid body'); + } } @Delete(':noteIdOrAlias') - deleteNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { - return this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias); + async deleteNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { + this.logger.debug('Deleting note: ' + noteIdOrAlias); + await this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias); + this.logger.debug('Successfully deleted ' + noteIdOrAlias); + return; } @Put(':noteIdOrAlias') - updateNote( + async updateNote( @Param('noteIdOrAlias') noteIdOrAlias: string, - @Body() noteContent: string, + @Req() req: Request, ) { - return this.noteService.updateNoteByIdOrAlias(noteIdOrAlias, noteContent); + // we have to check req.readable because of raw-body issue #57 + // https://github.com/stream-utils/raw-body/issues/57 + if (req.readable) { + let bodyText: string = await getRawBody(req, 'utf-8'); + bodyText = bodyText.trim(); + this.logger.debug('Got raw markdown:\n' + bodyText); + return this.noteService.updateNoteByIdOrAlias(noteIdOrAlias, bodyText); + } else { + // TODO: Better error message + throw new BadRequestException('Invalid body'); + } } @Get(':noteIdOrAlias/content') @@ -77,7 +122,7 @@ export class NotesController { @Get(':noteIdOrAlias/revisions/:revisionId') getNoteRevision( @Param('noteIdOrAlias') noteIdOrAlias: string, - @Param('revisionId') revisionId: string, + @Param('revisionId') revisionId: number, ) { return this.revisionsService.getNoteRevision(noteIdOrAlias, revisionId); } diff --git a/src/notes/note.dto.ts b/src/notes/note.dto.ts index 40b209644..58917606d 100644 --- a/src/notes/note.dto.ts +++ b/src/notes/note.dto.ts @@ -7,7 +7,7 @@ export class NoteDto { content: string; @ValidateNested() - metdata: NoteMetadataDto; + metadata: NoteMetadataDto; @IsArray() @ValidateNested({ each: true }) diff --git a/src/notes/note.entity.ts b/src/notes/note.entity.ts index 151379dde..815e7c643 100644 --- a/src/notes/note.entity.ts +++ b/src/notes/note.entity.ts @@ -16,64 +16,64 @@ import { AuthorColor } from './author-color.entity'; export class Note { @PrimaryGeneratedColumn('uuid') id: string; - @Column({ nullable: false, unique: true, }) shortid: string; - @Column({ unique: true, nullable: true, }) alias: string; - @OneToMany( _ => NoteGroupPermission, groupPermission => groupPermission.note, ) groupPermissions: NoteGroupPermission[]; - @OneToMany( _ => NoteUserPermission, userPermission => userPermission.note, ) userPermissions: NoteUserPermission[]; - @Column({ nullable: false, default: 0, }) viewcount: number; - @ManyToOne( _ => User, user => user.ownedNotes, { onDelete: 'CASCADE' }, ) owner: User; - @OneToMany( _ => Revision, revision => revision.note, + { cascade: true }, ) - revisions: Revision[]; - + revisions: Promise; @OneToMany( _ => AuthorColor, authorColor => authorColor.note, ) authorColors: AuthorColor[]; - constructor(shortid: string, alias: string, owner: User) { - if (shortid) { - this.shortid = shortid; - } else { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - this.shortid = shortIdGenerate() as string; + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + public static create(owner?: User, alias?: string, shortid?: string) { + if (!shortid) { + shortid = shortIdGenerate(); } - this.alias = alias; - this.owner = owner; + const newNote = new Note(); + newNote.shortid = shortid; + newNote.alias = alias; + newNote.viewcount = 0; + newNote.owner = owner; + newNote.authorColors = []; + newNote.userPermissions = []; + newNote.groupPermissions = []; + return newNote; } } diff --git a/src/notes/note.utils.ts b/src/notes/note.utils.ts new file mode 100644 index 000000000..d84c4b182 --- /dev/null +++ b/src/notes/note.utils.ts @@ -0,0 +1,18 @@ +import { Note } from './note.entity'; + +export class NoteUtils { + public static parseTitle(note: Note): string { + // TODO: Implement method + return 'Hardcoded note title'; + } + + public static parseDescription(note: Note): string { + // TODO: Implement method + return 'Hardcoded note description'; + } + + public static parseTags(note: Note): string[] { + // TODO: Implement method + return ['Hardcoded note tag']; + } +} diff --git a/src/notes/notes.module.ts b/src/notes/notes.module.ts index 1054935d3..0d2470ba3 100644 --- a/src/notes/notes.module.ts +++ b/src/notes/notes.module.ts @@ -1,11 +1,17 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { RevisionsModule } from '../revisions/revisions.module'; +import { UsersModule } from '../users/users.module'; import { AuthorColor } from './author-color.entity'; import { Note } from './note.entity'; import { NotesService } from './notes.service'; @Module({ - imports: [TypeOrmModule.forFeature([Note, AuthorColor])], + imports: [ + TypeOrmModule.forFeature([Note, AuthorColor]), + forwardRef(() => RevisionsModule), + UsersModule, + ], controllers: [], providers: [NotesService], exports: [NotesService], diff --git a/src/notes/notes.service.spec.ts b/src/notes/notes.service.spec.ts index 18f1c22c6..53d44e3d2 100644 --- a/src/notes/notes.service.spec.ts +++ b/src/notes/notes.service.spec.ts @@ -1,4 +1,14 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { Authorship } from '../revisions/authorship.entity'; +import { Revision } from '../revisions/revision.entity'; +import { RevisionsModule } from '../revisions/revisions.module'; +import { AuthToken } from '../users/auth-token.entity'; +import { Identity } from '../users/identity.entity'; +import { User } from '../users/user.entity'; +import { UsersModule } from '../users/users.module'; +import { AuthorColor } from './author-color.entity'; +import { Note } from './note.entity'; import { NotesService } from './notes.service'; describe('NotesService', () => { @@ -6,9 +16,30 @@ describe('NotesService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [NotesService], - }).compile(); - + providers: [ + NotesService, + { + provide: getRepositoryToken(Note), + useValue: {}, + }, + ], + imports: [UsersModule, RevisionsModule], + }) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .compile(); service = module.get(NotesService); }); diff --git a/src/notes/notes.service.ts b/src/notes/notes.service.ts index aebd48c5e..9557cc069 100644 --- a/src/notes/notes.service.ts +++ b/src/notes/notes.service.ts @@ -1,15 +1,30 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { Revision } from '../revisions/revision.entity'; +import { RevisionsService } from '../revisions/revisions.service'; +import { User } from '../users/user.entity'; +import { UsersService } from '../users/users.service'; import { NoteMetadataDto } from './note-metadata.dto'; import { NotePermissionsDto, NotePermissionsUpdateDto, } from './note-permissions.dto'; import { NoteDto } from './note.dto'; +import { Note } from './note.entity'; +import { NoteUtils } from './note.utils'; @Injectable() export class NotesService { private readonly logger = new Logger(NotesService.name); + constructor( + @InjectRepository(Note) private noteRepository: Repository, + @Inject(UsersService) private usersService: UsersService, + @Inject(forwardRef(() => RevisionsService)) + private revisionsService: RevisionsService, + ) {} + getUserNotes(username: string): NoteMetadataDto[] { this.logger.warn('Using hardcoded data!'); return [ @@ -43,140 +58,70 @@ export class NotesService { ]; } - createNote(noteContent: string, alias?: NoteMetadataDto['alias']): NoteDto { - this.logger.warn('Using hardcoded data!'); - return { - content: noteContent, - metdata: { - alias: alias, - createTime: new Date(), - description: 'Very descriptive text.', - editedBy: [], - id: 'foobar-barfoo', - permission: { - owner: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - sharedToUsers: [], - sharedToGroups: [], - }, - tags: [], - title: 'Title!', - updateTime: new Date(), - updateUser: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - viewCount: 42, - }, - editedByAtPosition: [], - }; + async createNoteDto( + noteContent: string, + alias?: NoteMetadataDto['alias'], + owner?: User, + ): Promise { + const note = await this.createNote(noteContent, alias, owner); + return this.toNoteDto(note); } - getNoteByIdOrAlias(noteIdOrAlias: string) { - this.logger.warn('Using hardcoded data!'); - return { - content: 'noteContent', - metdata: { - alias: null, - createTime: new Date(), - description: 'Very descriptive text.', - editedBy: [], - id: noteIdOrAlias, - permission: { - owner: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - sharedToUsers: [], - sharedToGroups: [], - }, - tags: [], - title: 'Title!', - updateTime: new Date(), - updateUser: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - viewCount: 42, - }, - editedByAtPosition: [], - }; + async createNote( + noteContent: string, + alias?: NoteMetadataDto['alias'], + owner?: User, + ): Promise { + const newNote = Note.create(); + newNote.revisions = Promise.resolve([ + //TODO: Calculate patch + Revision.create(noteContent, noteContent), + ]); + if (alias) { + newNote.alias = alias; + } + if (owner) { + newNote.owner = owner; + } + return this.noteRepository.save(newNote); } - deleteNoteByIdOrAlias(noteIdOrAlias: string) { - this.logger.warn('Using hardcoded data!'); - return; + async getCurrentContent(note: Note) { + return (await this.getLastRevision(note)).content; } - updateNoteByIdOrAlias(noteIdOrAlias: string, noteContent: string) { - this.logger.warn('Using hardcoded data!'); - return { - content: noteContent, - metdata: { - alias: null, - createTime: new Date(), - description: 'Very descriptive text.', - editedBy: [], - id: noteIdOrAlias, - permission: { - owner: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - sharedToUsers: [], - sharedToGroups: [], - }, - tags: [], - title: 'Title!', - updateTime: new Date(), - updateUser: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - viewCount: 42, - }, - editedByAtPosition: [], - }; + async getLastRevision(note: Note): Promise { + return this.revisionsService.getLatestRevision(note.id); } - getNoteMetadata(noteIdOrAlias: string): NoteMetadataDto { - this.logger.warn('Using hardcoded data!'); + async getMetadata(note: Note): Promise { return { - alias: null, + // TODO: Convert DB UUID to base64 + id: note.id, + alias: note.alias, + title: NoteUtils.parseTitle(note), + // TODO: Get actual createTime createTime: new Date(), - description: 'Very descriptive text.', - editedBy: [], - id: noteIdOrAlias, + description: NoteUtils.parseDescription(note), + editedBy: note.authorColors.map(authorColor => authorColor.user.userName), + // TODO: Extract into method permission: { - owner: { - displayName: 'foo', - userName: 'fooUser', - email: 'foo@example.com', - photo: '', - }, - sharedToUsers: [], - sharedToGroups: [], + owner: this.usersService.toUserDto(note.owner), + sharedToUsers: note.userPermissions.map(noteUserPermission => ({ + user: this.usersService.toUserDto(noteUserPermission.user), + canEdit: noteUserPermission.canEdit, + })), + sharedToGroups: note.groupPermissions.map(noteGroupPermission => ({ + group: noteGroupPermission.group, + canEdit: noteGroupPermission.canEdit, + })), }, - tags: [], - title: 'Title!', - updateTime: new Date(), + tags: NoteUtils.parseTags(note), + updateTime: (await this.getLastRevision(note)).createdAt, + // TODO: Get actual updateUser updateUser: { - displayName: 'foo', - userName: 'fooUser', + displayName: 'Hardcoded User', + userName: 'hardcoded', email: 'foo@example.com', photo: '', }, @@ -184,6 +129,47 @@ export class NotesService { }; } + async getNoteByIdOrAlias(noteIdOrAlias: string): Promise { + const note = await this.noteRepository.findOne({ + where: [{ id: noteIdOrAlias }, { alias: noteIdOrAlias }], + relations: [ + 'authorColors', + 'owner', + 'groupPermissions', + 'userPermissions', + ], + }); + if (note === undefined) { + //TODO: Improve error handling + throw new Error('Note not found'); + } + return note; + } + + async getNoteDtoByIdOrAlias(noteIdOrAlias: string): Promise { + const note = await this.getNoteByIdOrAlias(noteIdOrAlias); + return this.toNoteDto(note); + } + + async deleteNoteByIdOrAlias(noteIdOrAlias: string) { + const note = await this.getNoteByIdOrAlias(noteIdOrAlias); + return await this.noteRepository.remove(note); + } + + async updateNoteByIdOrAlias(noteIdOrAlias: string, noteContent: string) { + const note = await this.getNoteByIdOrAlias(noteIdOrAlias); + const revisions = await note.revisions; + //TODO: Calculate patch + revisions.push(Revision.create(noteContent, noteContent)); + note.revisions = Promise.resolve(revisions); + await this.noteRepository.save(note); + } + + async getNoteMetadata(noteIdOrAlias: string): Promise { + const note = await this.getNoteByIdOrAlias(noteIdOrAlias); + return this.getMetadata(note); + } + updateNotePermissions( noteIdOrAlias: string, newPermissions: NotePermissionsUpdateDto, @@ -201,8 +187,16 @@ export class NotesService { }; } - getNoteContent(noteIdOrAlias: string) { - this.logger.warn('Using hardcoded data!'); - return '# Markdown'; + async getNoteContent(noteIdOrAlias: string): Promise { + const note = await this.getNoteByIdOrAlias(noteIdOrAlias); + return this.getCurrentContent(note); + } + + async toNoteDto(note: Note): Promise { + return { + content: await this.getCurrentContent(note), + metadata: await this.getMetadata(note), + editedByAtPosition: [], + }; } } diff --git a/src/revisions/revision-metadata.dto.ts b/src/revisions/revision-metadata.dto.ts index 897e64501..213e598de 100644 --- a/src/revisions/revision-metadata.dto.ts +++ b/src/revisions/revision-metadata.dto.ts @@ -2,11 +2,11 @@ import { IsDate, IsNumber, IsString } from 'class-validator'; import { Revision } from './revision.entity'; export class RevisionMetadataDto { - @IsString() + @IsNumber() id: Revision['id']; @IsDate() - updatedAt: Date; + createdAt: Date; @IsNumber() length: number; diff --git a/src/revisions/revision.dto.ts b/src/revisions/revision.dto.ts index c917f8990..3bebce22a 100644 --- a/src/revisions/revision.dto.ts +++ b/src/revisions/revision.dto.ts @@ -1,11 +1,13 @@ -import { IsString } from 'class-validator'; +import { IsDate, IsNumber, IsString } from 'class-validator'; import { Revision } from './revision.entity'; export class RevisionDto { - @IsString() + @IsNumber() id: Revision['id']; @IsString() content: string; @IsString() patch: string; + @IsDate() + createdAt: Date; } diff --git a/src/revisions/revision.entity.ts b/src/revisions/revision.entity.ts index 4427454e2..4cf7a93ba 100644 --- a/src/revisions/revision.entity.ts +++ b/src/revisions/revision.entity.ts @@ -16,8 +16,8 @@ import { Authorship } from './authorship.entity'; */ @Entity() export class Revision { - @PrimaryGeneratedColumn('uuid') - id: string; + @PrimaryGeneratedColumn() + id: number; /** * The patch from the previous revision to this one. @@ -65,4 +65,15 @@ export class Revision { ) @JoinTable() authorships: Authorship[]; + + // eslint-disable-next-line @typescript-eslint/no-empty-function + private constructor() {} + + static create(content: string, patch: string): Revision { + const newRevision = new Revision(); + newRevision.patch = patch; + newRevision.content = content; + newRevision.length = content.length; + return newRevision; + } } diff --git a/src/revisions/revisions.module.ts b/src/revisions/revisions.module.ts index 6b1bc365f..a959f703a 100644 --- a/src/revisions/revisions.module.ts +++ b/src/revisions/revisions.module.ts @@ -1,11 +1,15 @@ -import { Module } from '@nestjs/common'; +import { forwardRef, Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { NotesModule } from '../notes/notes.module'; import { Authorship } from './authorship.entity'; import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; @Module({ - imports: [TypeOrmModule.forFeature([Revision, Authorship])], + imports: [ + TypeOrmModule.forFeature([Revision, Authorship]), + forwardRef(() => NotesModule), + ], providers: [RevisionsService], exports: [RevisionsService], }) diff --git a/src/revisions/revisions.service.spec.ts b/src/revisions/revisions.service.spec.ts index b9685df92..658f8e5e9 100644 --- a/src/revisions/revisions.service.spec.ts +++ b/src/revisions/revisions.service.spec.ts @@ -1,4 +1,13 @@ import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AuthorColor } from '../notes/author-color.entity'; +import { Note } from '../notes/note.entity'; +import { NotesModule } from '../notes/notes.module'; +import { AuthToken } from '../users/auth-token.entity'; +import { Identity } from '../users/identity.entity'; +import { User } from '../users/user.entity'; +import { Authorship } from './authorship.entity'; +import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; describe('RevisionsService', () => { @@ -6,8 +15,30 @@ describe('RevisionsService', () => { beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ - providers: [RevisionsService], - }).compile(); + providers: [ + RevisionsService, + { + provide: getRepositoryToken(Revision), + useValue: {}, + }, + ], + imports: [NotesModule], + }) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .compile(); service = module.get(RevisionsService); }); diff --git a/src/revisions/revisions.service.ts b/src/revisions/revisions.service.ts index f93e17eb2..8b4742b6f 100644 --- a/src/revisions/revisions.service.ts +++ b/src/revisions/revisions.service.ts @@ -1,27 +1,83 @@ -import { Injectable, Logger } from '@nestjs/common'; +import { forwardRef, Inject, Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { NotesService } from '../notes/notes.service'; import { RevisionMetadataDto } from './revision-metadata.dto'; import { RevisionDto } from './revision.dto'; +import { Revision } from './revision.entity'; @Injectable() export class RevisionsService { private readonly logger = new Logger(RevisionsService.name); - getNoteRevisionMetadatas(noteIdOrAlias: string): RevisionMetadataDto[] { - this.logger.warn('Using hardcoded data!'); - return [ - { - id: 'some-uuid', - updatedAt: new Date(), - length: 42, + + constructor( + @InjectRepository(Revision) + private revisionRepository: Repository, + @Inject(forwardRef(() => NotesService)) private notesService: NotesService, + ) {} + + async getNoteRevisionMetadatas( + noteIdOrAlias: string, + ): Promise { + const note = await this.notesService.getNoteByIdOrAlias(noteIdOrAlias); + const revisions = await this.revisionRepository.find({ + where: { + note: note.id, }, - ]; + }); + return revisions.map(revision => this.toMetadataDto(revision)); } - getNoteRevision(noteIdOrAlias: string, revisionId: string): RevisionDto { - this.logger.warn('Using hardcoded data!'); + async getNoteRevision( + noteIdOrAlias: string, + revisionId: number, + ): Promise { + const note = await this.notesService.getNoteByIdOrAlias(noteIdOrAlias); + const revision = await this.revisionRepository.findOne({ + where: { + id: revisionId, + note: note, + }, + }); + return this.toDto(revision); + } + + getLatestRevision(noteId: string): Promise { + return this.revisionRepository.findOne({ + where: { + note: noteId, + }, + order: { + createdAt: 'DESC', + id: 'DESC', + }, + }); + } + + toMetadataDto(revision: Revision): RevisionMetadataDto { return { - id: revisionId, - content: 'Foobar', - patch: 'barfoo', + id: revision.id, + length: revision.length, + createdAt: revision.createdAt, }; } + + toDto(revision: Revision): RevisionDto { + return { + id: revision.id, + content: revision.content, + createdAt: revision.createdAt, + patch: revision.patch, + }; + } + + createRevision(content: string) { + // TODO: Add previous revision + // TODO: Calculate patch + return this.revisionRepository.create({ + content: content, + length: content.length, + patch: '', + }); + } } diff --git a/src/users/users.service.ts b/src/users/users.service.ts index c558775ac..88e5fbbf9 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from '@nestjs/common'; import { UserInfoDto } from './user-info.dto'; +import { User } from './user.entity'; @Injectable() export class UsersService { @@ -15,4 +16,26 @@ export class UsersService { photo: '', }; } + + getPhotoUrl(user: User) { + if (user.photo) { + return user.photo; + } else { + // TODO: Create new photo, see old code + return ''; + } + } + + toUserDto(user: User | null | undefined): UserInfoDto | null { + if (!user) { + this.logger.warn(`toUserDto recieved ${user} argument!`); + return null; + } + return { + userName: user.userName, + displayName: user.displayName, + photo: this.getPhotoUrl(user), + email: user.email, + }; + } } diff --git a/test/public-api/notes.e2e-spec.ts b/test/public-api/notes.e2e-spec.ts index 3446c0f44..a68349be1 100644 --- a/test/public-api/notes.e2e-spec.ts +++ b/test/public-api/notes.e2e-spec.ts @@ -1,8 +1,12 @@ import { INestApplication } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; import * as request from 'supertest'; -import { AppModule } from '../../src/app.module'; +import { PublicApiModule } from '../../src/api/public/public-api.module'; +import { GroupsModule } from '../../src/groups/groups.module'; +import { NotesModule } from '../../src/notes/notes.module'; import { NotesService } from '../../src/notes/notes.service'; +import { PermissionsModule } from '../../src/permissions/permissions.module'; describe('Notes', () => { let app: INestApplication; @@ -10,29 +14,44 @@ describe('Notes', () => { beforeAll(async () => { const moduleRef = await Test.createTestingModule({ - imports: [AppModule], + imports: [ + PublicApiModule, + NotesModule, + PermissionsModule, + GroupsModule, + TypeOrmModule.forRoot({ + type: 'sqlite', + database: './hedgedoc-e2e.sqlite', + autoLoadEntities: true, + synchronize: true, + }), + ], }).compile(); app = moduleRef.createNestApplication(); - notesService = moduleRef.get(NotesService); await app.init(); + notesService = moduleRef.get(NotesService); + const noteRepository = moduleRef.get('NoteRepository'); + noteRepository.clear(); }); it(`POST /notes`, async () => { const newNote = 'This is a test note.'; const response = await request(app.getHttpServer()) .post('/notes') + .set('Content-Type', 'text/markdown') .send(newNote) .expect('Content-Type', /json/) .expect(201); expect(response.body.metadata?.id).toBeDefined(); expect( - notesService.getNoteByIdOrAlias(response.body.metadata.id).content, + (await notesService.getNoteDtoByIdOrAlias(response.body.metadata.id)) + .content, ).toEqual(newNote); }); it(`GET /notes/{note}`, async () => { - notesService.createNote('This is a test note.', 'test1'); + await notesService.createNote('This is a test note.', 'test1'); const response = await request(app.getHttpServer()) .get('/notes/test1') .expect('Content-Type', /json/) @@ -44,38 +63,44 @@ describe('Notes', () => { const newNote = 'This is a test note.'; const response = await request(app.getHttpServer()) .post('/notes/test2') + .set('Content-Type', 'text/markdown') .send(newNote) .expect('Content-Type', /json/) .expect(201); expect(response.body.metadata?.id).toBeDefined(); return expect( - notesService.getNoteByIdOrAlias(response.body.metadata.id).content, + (await notesService.getNoteDtoByIdOrAlias(response.body.metadata.id)) + .content, ).toEqual(newNote); }); it(`DELETE /notes/{note}`, async () => { - notesService.createNote('This is a test note.', 'test3'); + await notesService.createNote('This is a test note.', 'test3'); await request(app.getHttpServer()) .delete('/notes/test3') .expect(200); - return expect(notesService.getNoteByIdOrAlias('test3')).toBeNull(); + return expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual( + Error('Note not found'), + ); }); it(`PUT /notes/{note}`, async () => { - notesService.createNote('This is a test note.', 'test4'); + await notesService.createNote('This is a test note.', 'test4'); await request(app.getHttpServer()) .put('/notes/test4') + .set('Content-Type', 'text/markdown') .send('New note text') .expect(200); - return expect(notesService.getNoteByIdOrAlias('test4').content).toEqual( - 'New note text', - ); + return expect( + (await notesService.getNoteDtoByIdOrAlias('test4')).content, + ).toEqual('New note text'); }); it.skip(`PUT /notes/{note}/metadata`, () => { // TODO return request(app.getHttpServer()) .post('/notes/test5/metadata') + .set('Content-Type', 'text/markdown') .expect(200); }); @@ -88,29 +113,30 @@ describe('Notes', () => { }); it(`GET /notes/{note}/revisions`, async () => { - notesService.createNote('This is a test note.', 'test7'); + await notesService.createNote('This is a test note.', 'test7'); const response = await request(app.getHttpServer()) .get('/notes/test7/revisions') .expect('Content-Type', /json/) .expect(200); - expect(response.body.revisions).toHaveLength(1); + expect(response.body).toHaveLength(1); }); it(`GET /notes/{note}/revisions/{revision-id}`, async () => { - notesService.createNote('This is a test note.', 'test8'); + const note = await notesService.createNote('This is a test note.', 'test8'); + const revision = await notesService.getLastRevision(note); const response = await request(app.getHttpServer()) - .get('/notes/test8/revisions/1') + .get('/notes/test8/revisions/' + revision.id) .expect('Content-Type', /json/) .expect(200); expect(response.body.content).toEqual('This is a test note.'); }); it(`GET /notes/{note}/content`, async () => { - notesService.createNote('This is a test note.', 'test9'); + await notesService.createNote('This is a test note.', 'test9'); const response = await request(app.getHttpServer()) .get('/notes/test9/content') .expect(200); - expect(response.body).toEqual('This is a test note.'); + expect(response.text).toEqual('This is a test note.'); }); afterAll(async () => { diff --git a/yarn.lock b/yarn.lock index 1dd3417ae..bfe54b370 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3622,7 +3622,7 @@ http-errors@1.7.2: statuses ">= 1.5.0 < 2" toidentifier "1.0.0" -http-errors@~1.7.2: +http-errors@1.7.3, http-errors@~1.7.2: version "1.7.3" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== @@ -5944,6 +5944,16 @@ raw-body@2.4.0: iconv-lite "0.4.24" unpipe "1.0.0" +raw-body@^2.4.1: + version "2.4.1" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.4.1.tgz#30ac82f98bb5ae8c152e67149dac8d55153b168c" + integrity sha512-9WmIKF6mkvA0SLmA2Knm9+qj89e+j1zqgyn8aXGd7+nAduPoqgI9lO57SAZNn/Byzo5P7JhXTyg9PzaJbH73bA== + dependencies: + bytes "3.1.0" + http-errors "1.7.3" + iconv-lite "0.4.24" + unpipe "1.0.0" + rc@^1.2.7: version "1.2.8" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"