Merge pull request #500 from codimd/routes/notes/services

This commit is contained in:
David Mehren 2020-09-26 18:03:42 +02:00 committed by GitHub
commit d07d8fe278
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
21 changed files with 513 additions and 229 deletions

View file

@ -58,7 +58,7 @@ entity "Session" as seesion {
entity "Revision" { entity "Revision" {
*id : uuid <<generated>> *id : number <<generated>>
-- --
*noteId : uuid <<FK Note>> *noteId : uuid <<FK Note>>
*content : text *content : text
@ -78,7 +78,7 @@ entity "Authorship" {
} }
entity "RevisionAuthorship" { entity "RevisionAuthorship" {
*revisionId : uuid <<FK Revision>> *revisionId : number <<FK Revision>>
*authorshipId : uuid <<FK Authorship>> *authorshipId : uuid <<FK Authorship>>
} }
@ -115,11 +115,11 @@ entity "Group" {
*canEdit : boolean *canEdit : boolean
} }
Note "1" -- "1..*" Revision Note "1" - "1..*" Revision
Revision "0..*" -- "0..*" Authorship Revision "0..*" - "0..*" Authorship
(Revision, Authorship) .. RevisionAuthorship (Revision, Authorship) .. RevisionAuthorship
Authorship "0..*" -- "1" User Authorship "0..*" -- "1" User
Note "1" -- "0..*" User : owner Note "0..*" -- "1" User : owner
Note "1" -- "0..*" NoteUserPermission Note "1" -- "0..*" NoteUserPermission
NoteUserPermission "1" -- "1" User NoteUserPermission "1" -- "1" User
Note "1" -- "0..*" NoteGroupPermission Note "1" -- "0..*" NoteGroupPermission

View file

@ -368,7 +368,7 @@ paths:
- note - note
summary: Returns a list of the available note revisions summary: Returns a list of the available note revisions
operationId: getAllRevisionsOfNote 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: responses:
'200': '200':
description: Revisions of the note. 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. description: The revision is returned as a JSON object with the content of the note and the authorship.
responses: responses:
'200': '200':
description: Revision of the note for the given timestamp. description: Revision of the note for the given id.
content: content:
application/json: application/json:
schema: schema:
@ -421,7 +421,7 @@ paths:
- name: revision-id - name: revision-id
in: path in: path
required: true required: true
description: The id (timestamp) of the revision to fetch. description: The id of the revision to fetch.
content: content:
text/plain: text/plain:
example: 1570921051959 example: 1570921051959
@ -579,7 +579,7 @@ components:
description: A tag description: A tag
updateTime: updateTime:
type: integer type: integer
description: UNIX-timestamp of when the note was last changed. description: ISO-timestamp of when the note was last changed.
updateUser: updateUser:
$ref: "#/components/schemas/UserInfo" $ref: "#/components/schemas/UserInfo"
viewCount: viewCount:
@ -588,7 +588,7 @@ components:
description: How often the published version of the note was viewed. description: How often the published version of the note was viewed.
createTime: createTime:
type: string 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: editedBy:
type: array type: array
description: List of usernames who edited the note. description: List of usernames who edited the note.
@ -614,20 +614,19 @@ components:
type: boolean type: boolean
NoteRevisionsMetadata: NoteRevisionsMetadata:
type: object type: array
properties: items:
revision: type: object
type: array properties:
description: Array that holds all revision-info objects. id:
items: type: integer
type: object description: The id of the revision
properties: createdAt:
time: type: integer
type: integer description: ISO-timestamp of when the revision was saved. Is also the revision-id.
description: UNIX-timestamp of when the revision was saved. Is also the revision-id. length:
length: type: integer
type: integer description: Length of the document to the timepoint the revision was saved.
description: Length of the document to the timepoint the revision was saved.
NoteRevision: NoteRevision:
type: object type: object
properties: properties:

View file

@ -30,6 +30,7 @@
"class-transformer": "^0.2.3", "class-transformer": "^0.2.3",
"class-validator": "^0.12.2", "class-validator": "^0.12.2",
"connect-typeorm": "^1.1.4", "connect-typeorm": "^1.1.4",
"raw-body": "^2.4.1",
"reflect-metadata": "^0.1.13", "reflect-metadata": "^0.1.13",
"rimraf": "^3.0.2", "rimraf": "^3.0.2",
"rxjs": "^6.5.4", "rxjs": "^6.5.4",

View file

@ -4,6 +4,8 @@ import { HistoryModule } from '../../../history/history.module';
import { AuthorColor } from '../../../notes/author-color.entity'; import { AuthorColor } from '../../../notes/author-color.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesModule } from '../../../notes/notes.module'; 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 { AuthToken } from '../../../users/auth-token.entity';
import { Identity } from '../../../users/identity.entity'; import { Identity } from '../../../users/identity.entity';
import { User } from '../../../users/user.entity'; import { User } from '../../../users/user.entity';
@ -28,6 +30,10 @@ describe('Me Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(AuthorColor)) .overrideProvider(getRepositoryToken(AuthorColor))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Authorship))
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.compile(); .compile();
controller = module.get<MeController>(MeController); controller = module.get<MeController>(MeController);

View file

@ -1,10 +1,15 @@
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm'; import { getRepositoryToken } from '@nestjs/typeorm';
import { AuthorColor } from '../../../notes/author-color.entity';
import { Note } from '../../../notes/note.entity'; import { Note } from '../../../notes/note.entity';
import { NotesService } from '../../../notes/notes.service'; import { NotesService } from '../../../notes/notes.service';
import { Authorship } from '../../../revisions/authorship.entity'; import { Authorship } from '../../../revisions/authorship.entity';
import { Revision } from '../../../revisions/revision.entity'; import { Revision } from '../../../revisions/revision.entity';
import { RevisionsModule } from '../../../revisions/revisions.module'; 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'; import { NotesController } from './notes.controller';
describe('Notes Controller', () => { describe('Notes Controller', () => {
@ -13,8 +18,14 @@ describe('Notes Controller', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
controllers: [NotesController], controllers: [NotesController],
providers: [NotesService], providers: [
imports: [RevisionsModule], NotesService,
{
provide: getRepositoryToken(Note),
useValue: {},
},
],
imports: [RevisionsModule, UsersModule],
}) })
.overrideProvider(getRepositoryToken(Note)) .overrideProvider(getRepositoryToken(Note))
.useValue({}) .useValue({})
@ -22,6 +33,16 @@ describe('Notes Controller', () => {
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(Authorship)) .overrideProvider(getRepositoryToken(Authorship))
.useValue({}) .useValue({})
.overrideProvider(getRepositoryToken(AuthorColor))
.useValue({})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useValue({})
.compile(); .compile();
controller = module.get<NotesController>(NotesController); controller = module.get<NotesController>(NotesController);

View file

@ -1,53 +1,98 @@
import { import {
BadRequestException,
Body, Body,
Controller, Controller,
Delete, Delete,
Get, Get,
Header, Header,
Logger,
Param, Param,
Post, Post,
Put, Put,
Req,
} from '@nestjs/common'; } from '@nestjs/common';
import { Request } from 'express';
import * as getRawBody from 'raw-body';
import { NotePermissionsUpdateDto } from '../../../notes/note-permissions.dto'; import { NotePermissionsUpdateDto } from '../../../notes/note-permissions.dto';
import { NotesService } from '../../../notes/notes.service'; import { NotesService } from '../../../notes/notes.service';
import { RevisionsService } from '../../../revisions/revisions.service'; import { RevisionsService } from '../../../revisions/revisions.service';
@Controller('notes') @Controller('notes')
export class NotesController { export class NotesController {
private readonly logger = new Logger(NotesController.name);
constructor( constructor(
private noteService: NotesService, private noteService: NotesService,
private revisionsService: RevisionsService, 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() @Post()
createNote(@Body() noteContent: string) { async createNote(@Req() req: Request) {
return this.noteService.createNote(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.createNoteDto(bodyText);
} else {
// TODO: Better error message
throw new BadRequestException('Invalid body');
}
} }
@Get(':noteIdOrAlias') @Get(':noteIdOrAlias')
getNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { getNote(@Param('noteIdOrAlias') noteIdOrAlias: string) {
return this.noteService.getNoteByIdOrAlias(noteIdOrAlias); return this.noteService.getNoteDtoByIdOrAlias(noteIdOrAlias);
} }
@Post(':noteAlias') @Post(':noteAlias')
createNamedNote( async createNamedNote(
@Param('noteAlias') noteAlias: string, @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') @Delete(':noteIdOrAlias')
deleteNote(@Param('noteIdOrAlias') noteIdOrAlias: string) { async deleteNote(@Param('noteIdOrAlias') noteIdOrAlias: string) {
return this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias); this.logger.debug('Deleting note: ' + noteIdOrAlias);
await this.noteService.deleteNoteByIdOrAlias(noteIdOrAlias);
this.logger.debug('Successfully deleted ' + noteIdOrAlias);
return;
} }
@Put(':noteIdOrAlias') @Put(':noteIdOrAlias')
updateNote( async updateNote(
@Param('noteIdOrAlias') noteIdOrAlias: string, @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') @Get(':noteIdOrAlias/content')
@ -77,7 +122,7 @@ export class NotesController {
@Get(':noteIdOrAlias/revisions/:revisionId') @Get(':noteIdOrAlias/revisions/:revisionId')
getNoteRevision( getNoteRevision(
@Param('noteIdOrAlias') noteIdOrAlias: string, @Param('noteIdOrAlias') noteIdOrAlias: string,
@Param('revisionId') revisionId: string, @Param('revisionId') revisionId: number,
) { ) {
return this.revisionsService.getNoteRevision(noteIdOrAlias, revisionId); return this.revisionsService.getNoteRevision(noteIdOrAlias, revisionId);
} }

View file

@ -7,7 +7,7 @@ export class NoteDto {
content: string; content: string;
@ValidateNested() @ValidateNested()
metdata: NoteMetadataDto; metadata: NoteMetadataDto;
@IsArray() @IsArray()
@ValidateNested({ each: true }) @ValidateNested({ each: true })

View file

@ -16,64 +16,64 @@ import { AuthorColor } from './author-color.entity';
export class Note { export class Note {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;
@Column({ @Column({
nullable: false, nullable: false,
unique: true, unique: true,
}) })
shortid: string; shortid: string;
@Column({ @Column({
unique: true, unique: true,
nullable: true, nullable: true,
}) })
alias: string; alias: string;
@OneToMany( @OneToMany(
_ => NoteGroupPermission, _ => NoteGroupPermission,
groupPermission => groupPermission.note, groupPermission => groupPermission.note,
) )
groupPermissions: NoteGroupPermission[]; groupPermissions: NoteGroupPermission[];
@OneToMany( @OneToMany(
_ => NoteUserPermission, _ => NoteUserPermission,
userPermission => userPermission.note, userPermission => userPermission.note,
) )
userPermissions: NoteUserPermission[]; userPermissions: NoteUserPermission[];
@Column({ @Column({
nullable: false, nullable: false,
default: 0, default: 0,
}) })
viewcount: number; viewcount: number;
@ManyToOne( @ManyToOne(
_ => User, _ => User,
user => user.ownedNotes, user => user.ownedNotes,
{ onDelete: 'CASCADE' }, { onDelete: 'CASCADE' },
) )
owner: User; owner: User;
@OneToMany( @OneToMany(
_ => Revision, _ => Revision,
revision => revision.note, revision => revision.note,
{ cascade: true },
) )
revisions: Revision[]; revisions: Promise<Revision[]>;
@OneToMany( @OneToMany(
_ => AuthorColor, _ => AuthorColor,
authorColor => authorColor.note, authorColor => authorColor.note,
) )
authorColors: AuthorColor[]; authorColors: AuthorColor[];
constructor(shortid: string, alias: string, owner: User) { // eslint-disable-next-line @typescript-eslint/no-empty-function
if (shortid) { private constructor() {}
this.shortid = shortid;
} else { public static create(owner?: User, alias?: string, shortid?: string) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call if (!shortid) {
this.shortid = shortIdGenerate() as string; shortid = shortIdGenerate();
} }
this.alias = alias; const newNote = new Note();
this.owner = owner; newNote.shortid = shortid;
newNote.alias = alias;
newNote.viewcount = 0;
newNote.owner = owner;
newNote.authorColors = [];
newNote.userPermissions = [];
newNote.groupPermissions = [];
return newNote;
} }
} }

18
src/notes/note.utils.ts Normal file
View file

@ -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'];
}
}

View file

@ -1,11 +1,17 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { RevisionsModule } from '../revisions/revisions.module';
import { UsersModule } from '../users/users.module';
import { AuthorColor } from './author-color.entity'; import { AuthorColor } from './author-color.entity';
import { Note } from './note.entity'; import { Note } from './note.entity';
import { NotesService } from './notes.service'; import { NotesService } from './notes.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Note, AuthorColor])], imports: [
TypeOrmModule.forFeature([Note, AuthorColor]),
forwardRef(() => RevisionsModule),
UsersModule,
],
controllers: [], controllers: [],
providers: [NotesService], providers: [NotesService],
exports: [NotesService], exports: [NotesService],

View file

@ -1,4 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing'; 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'; import { NotesService } from './notes.service';
describe('NotesService', () => { describe('NotesService', () => {
@ -6,9 +16,30 @@ describe('NotesService', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [NotesService], providers: [
}).compile(); 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>(NotesService); service = module.get<NotesService>(NotesService);
}); });

View file

@ -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 { NoteMetadataDto } from './note-metadata.dto';
import { import {
NotePermissionsDto, NotePermissionsDto,
NotePermissionsUpdateDto, NotePermissionsUpdateDto,
} from './note-permissions.dto'; } from './note-permissions.dto';
import { NoteDto } from './note.dto'; import { NoteDto } from './note.dto';
import { Note } from './note.entity';
import { NoteUtils } from './note.utils';
@Injectable() @Injectable()
export class NotesService { export class NotesService {
private readonly logger = new Logger(NotesService.name); private readonly logger = new Logger(NotesService.name);
constructor(
@InjectRepository(Note) private noteRepository: Repository<Note>,
@Inject(UsersService) private usersService: UsersService,
@Inject(forwardRef(() => RevisionsService))
private revisionsService: RevisionsService,
) {}
getUserNotes(username: string): NoteMetadataDto[] { getUserNotes(username: string): NoteMetadataDto[] {
this.logger.warn('Using hardcoded data!'); this.logger.warn('Using hardcoded data!');
return [ return [
@ -43,140 +58,70 @@ export class NotesService {
]; ];
} }
createNote(noteContent: string, alias?: NoteMetadataDto['alias']): NoteDto { async createNoteDto(
this.logger.warn('Using hardcoded data!'); noteContent: string,
return { alias?: NoteMetadataDto['alias'],
content: noteContent, owner?: User,
metdata: { ): Promise<NoteDto> {
alias: alias, const note = await this.createNote(noteContent, alias, owner);
createTime: new Date(), return this.toNoteDto(note);
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: [],
};
} }
getNoteByIdOrAlias(noteIdOrAlias: string) { async createNote(
this.logger.warn('Using hardcoded data!'); noteContent: string,
return { alias?: NoteMetadataDto['alias'],
content: 'noteContent', owner?: User,
metdata: { ): Promise<Note> {
alias: null, const newNote = Note.create();
createTime: new Date(), newNote.revisions = Promise.resolve([
description: 'Very descriptive text.', //TODO: Calculate patch
editedBy: [], Revision.create(noteContent, noteContent),
id: noteIdOrAlias, ]);
permission: { if (alias) {
owner: { newNote.alias = alias;
displayName: 'foo', }
userName: 'fooUser', if (owner) {
email: 'foo@example.com', newNote.owner = owner;
photo: '', }
}, return this.noteRepository.save(newNote);
sharedToUsers: [],
sharedToGroups: [],
},
tags: [],
title: 'Title!',
updateTime: new Date(),
updateUser: {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
},
viewCount: 42,
},
editedByAtPosition: [],
};
} }
deleteNoteByIdOrAlias(noteIdOrAlias: string) { async getCurrentContent(note: Note) {
this.logger.warn('Using hardcoded data!'); return (await this.getLastRevision(note)).content;
return;
} }
updateNoteByIdOrAlias(noteIdOrAlias: string, noteContent: string) { async getLastRevision(note: Note): Promise<Revision> {
this.logger.warn('Using hardcoded data!'); return this.revisionsService.getLatestRevision(note.id);
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: [],
};
} }
getNoteMetadata(noteIdOrAlias: string): NoteMetadataDto { async getMetadata(note: Note): Promise<NoteMetadataDto> {
this.logger.warn('Using hardcoded data!');
return { 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(), createTime: new Date(),
description: 'Very descriptive text.', description: NoteUtils.parseDescription(note),
editedBy: [], editedBy: note.authorColors.map(authorColor => authorColor.user.userName),
id: noteIdOrAlias, // TODO: Extract into method
permission: { permission: {
owner: { owner: this.usersService.toUserDto(note.owner),
displayName: 'foo', sharedToUsers: note.userPermissions.map(noteUserPermission => ({
userName: 'fooUser', user: this.usersService.toUserDto(noteUserPermission.user),
email: 'foo@example.com', canEdit: noteUserPermission.canEdit,
photo: '', })),
}, sharedToGroups: note.groupPermissions.map(noteGroupPermission => ({
sharedToUsers: [], group: noteGroupPermission.group,
sharedToGroups: [], canEdit: noteGroupPermission.canEdit,
})),
}, },
tags: [], tags: NoteUtils.parseTags(note),
title: 'Title!', updateTime: (await this.getLastRevision(note)).createdAt,
updateTime: new Date(), // TODO: Get actual updateUser
updateUser: { updateUser: {
displayName: 'foo', displayName: 'Hardcoded User',
userName: 'fooUser', userName: 'hardcoded',
email: 'foo@example.com', email: 'foo@example.com',
photo: '', photo: '',
}, },
@ -184,6 +129,47 @@ export class NotesService {
}; };
} }
async getNoteByIdOrAlias(noteIdOrAlias: string): Promise<Note> {
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<NoteDto> {
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<NoteMetadataDto> {
const note = await this.getNoteByIdOrAlias(noteIdOrAlias);
return this.getMetadata(note);
}
updateNotePermissions( updateNotePermissions(
noteIdOrAlias: string, noteIdOrAlias: string,
newPermissions: NotePermissionsUpdateDto, newPermissions: NotePermissionsUpdateDto,
@ -201,8 +187,16 @@ export class NotesService {
}; };
} }
getNoteContent(noteIdOrAlias: string) { async getNoteContent(noteIdOrAlias: string): Promise<string> {
this.logger.warn('Using hardcoded data!'); const note = await this.getNoteByIdOrAlias(noteIdOrAlias);
return '# Markdown'; return this.getCurrentContent(note);
}
async toNoteDto(note: Note): Promise<NoteDto> {
return {
content: await this.getCurrentContent(note),
metadata: await this.getMetadata(note),
editedByAtPosition: [],
};
} }
} }

View file

@ -2,11 +2,11 @@ import { IsDate, IsNumber, IsString } from 'class-validator';
import { Revision } from './revision.entity'; import { Revision } from './revision.entity';
export class RevisionMetadataDto { export class RevisionMetadataDto {
@IsString() @IsNumber()
id: Revision['id']; id: Revision['id'];
@IsDate() @IsDate()
updatedAt: Date; createdAt: Date;
@IsNumber() @IsNumber()
length: number; length: number;

View file

@ -1,11 +1,13 @@
import { IsString } from 'class-validator'; import { IsDate, IsNumber, IsString } from 'class-validator';
import { Revision } from './revision.entity'; import { Revision } from './revision.entity';
export class RevisionDto { export class RevisionDto {
@IsString() @IsNumber()
id: Revision['id']; id: Revision['id'];
@IsString() @IsString()
content: string; content: string;
@IsString() @IsString()
patch: string; patch: string;
@IsDate()
createdAt: Date;
} }

View file

@ -16,8 +16,8 @@ import { Authorship } from './authorship.entity';
*/ */
@Entity() @Entity()
export class Revision { export class Revision {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn()
id: string; id: number;
/** /**
* The patch from the previous revision to this one. * The patch from the previous revision to this one.
@ -65,4 +65,15 @@ export class Revision {
) )
@JoinTable() @JoinTable()
authorships: Authorship[]; 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;
}
} }

View file

@ -1,11 +1,15 @@
import { Module } from '@nestjs/common'; import { forwardRef, Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { NotesModule } from '../notes/notes.module';
import { Authorship } from './authorship.entity'; import { Authorship } from './authorship.entity';
import { Revision } from './revision.entity'; import { Revision } from './revision.entity';
import { RevisionsService } from './revisions.service'; import { RevisionsService } from './revisions.service';
@Module({ @Module({
imports: [TypeOrmModule.forFeature([Revision, Authorship])], imports: [
TypeOrmModule.forFeature([Revision, Authorship]),
forwardRef(() => NotesModule),
],
providers: [RevisionsService], providers: [RevisionsService],
exports: [RevisionsService], exports: [RevisionsService],
}) })

View file

@ -1,4 +1,13 @@
import { Test, TestingModule } from '@nestjs/testing'; 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'; import { RevisionsService } from './revisions.service';
describe('RevisionsService', () => { describe('RevisionsService', () => {
@ -6,8 +15,30 @@ describe('RevisionsService', () => {
beforeEach(async () => { beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({ const module: TestingModule = await Test.createTestingModule({
providers: [RevisionsService], providers: [
}).compile(); 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>(RevisionsService); service = module.get<RevisionsService>(RevisionsService);
}); });

View file

@ -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 { RevisionMetadataDto } from './revision-metadata.dto';
import { RevisionDto } from './revision.dto'; import { RevisionDto } from './revision.dto';
import { Revision } from './revision.entity';
@Injectable() @Injectable()
export class RevisionsService { export class RevisionsService {
private readonly logger = new Logger(RevisionsService.name); private readonly logger = new Logger(RevisionsService.name);
getNoteRevisionMetadatas(noteIdOrAlias: string): RevisionMetadataDto[] {
this.logger.warn('Using hardcoded data!'); constructor(
return [ @InjectRepository(Revision)
{ private revisionRepository: Repository<Revision>,
id: 'some-uuid', @Inject(forwardRef(() => NotesService)) private notesService: NotesService,
updatedAt: new Date(), ) {}
length: 42,
async getNoteRevisionMetadatas(
noteIdOrAlias: string,
): Promise<RevisionMetadataDto[]> {
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 { async getNoteRevision(
this.logger.warn('Using hardcoded data!'); noteIdOrAlias: string,
revisionId: number,
): Promise<RevisionDto> {
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<Revision> {
return this.revisionRepository.findOne({
where: {
note: noteId,
},
order: {
createdAt: 'DESC',
id: 'DESC',
},
});
}
toMetadataDto(revision: Revision): RevisionMetadataDto {
return { return {
id: revisionId, id: revision.id,
content: 'Foobar', length: revision.length,
patch: 'barfoo', 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: '',
});
}
} }

View file

@ -1,5 +1,6 @@
import { Injectable, Logger } from '@nestjs/common'; import { Injectable, Logger } from '@nestjs/common';
import { UserInfoDto } from './user-info.dto'; import { UserInfoDto } from './user-info.dto';
import { User } from './user.entity';
@Injectable() @Injectable()
export class UsersService { export class UsersService {
@ -15,4 +16,26 @@ export class UsersService {
photo: '', 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,
};
}
} }

View file

@ -1,8 +1,12 @@
import { INestApplication } from '@nestjs/common'; import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing'; import { Test } from '@nestjs/testing';
import { TypeOrmModule } from '@nestjs/typeorm';
import * as request from 'supertest'; 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 { NotesService } from '../../src/notes/notes.service';
import { PermissionsModule } from '../../src/permissions/permissions.module';
describe('Notes', () => { describe('Notes', () => {
let app: INestApplication; let app: INestApplication;
@ -10,29 +14,44 @@ describe('Notes', () => {
beforeAll(async () => { beforeAll(async () => {
const moduleRef = await Test.createTestingModule({ 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(); }).compile();
app = moduleRef.createNestApplication(); app = moduleRef.createNestApplication();
notesService = moduleRef.get(NotesService);
await app.init(); await app.init();
notesService = moduleRef.get(NotesService);
const noteRepository = moduleRef.get('NoteRepository');
noteRepository.clear();
}); });
it(`POST /notes`, async () => { it(`POST /notes`, async () => {
const newNote = 'This is a test note.'; const newNote = 'This is a test note.';
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/notes') .post('/notes')
.set('Content-Type', 'text/markdown')
.send(newNote) .send(newNote)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(201); .expect(201);
expect(response.body.metadata?.id).toBeDefined(); expect(response.body.metadata?.id).toBeDefined();
expect( expect(
notesService.getNoteByIdOrAlias(response.body.metadata.id).content, (await notesService.getNoteDtoByIdOrAlias(response.body.metadata.id))
.content,
).toEqual(newNote); ).toEqual(newNote);
}); });
it(`GET /notes/{note}`, async () => { 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()) const response = await request(app.getHttpServer())
.get('/notes/test1') .get('/notes/test1')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
@ -44,38 +63,44 @@ describe('Notes', () => {
const newNote = 'This is a test note.'; const newNote = 'This is a test note.';
const response = await request(app.getHttpServer()) const response = await request(app.getHttpServer())
.post('/notes/test2') .post('/notes/test2')
.set('Content-Type', 'text/markdown')
.send(newNote) .send(newNote)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(201); .expect(201);
expect(response.body.metadata?.id).toBeDefined(); expect(response.body.metadata?.id).toBeDefined();
return expect( return expect(
notesService.getNoteByIdOrAlias(response.body.metadata.id).content, (await notesService.getNoteDtoByIdOrAlias(response.body.metadata.id))
.content,
).toEqual(newNote); ).toEqual(newNote);
}); });
it(`DELETE /notes/{note}`, async () => { 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()) await request(app.getHttpServer())
.delete('/notes/test3') .delete('/notes/test3')
.expect(200); .expect(200);
return expect(notesService.getNoteByIdOrAlias('test3')).toBeNull(); return expect(notesService.getNoteByIdOrAlias('test3')).rejects.toEqual(
Error('Note not found'),
);
}); });
it(`PUT /notes/{note}`, async () => { 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()) await request(app.getHttpServer())
.put('/notes/test4') .put('/notes/test4')
.set('Content-Type', 'text/markdown')
.send('New note text') .send('New note text')
.expect(200); .expect(200);
return expect(notesService.getNoteByIdOrAlias('test4').content).toEqual( return expect(
'New note text', (await notesService.getNoteDtoByIdOrAlias('test4')).content,
); ).toEqual('New note text');
}); });
it.skip(`PUT /notes/{note}/metadata`, () => { it.skip(`PUT /notes/{note}/metadata`, () => {
// TODO // TODO
return request(app.getHttpServer()) return request(app.getHttpServer())
.post('/notes/test5/metadata') .post('/notes/test5/metadata')
.set('Content-Type', 'text/markdown')
.expect(200); .expect(200);
}); });
@ -88,29 +113,30 @@ describe('Notes', () => {
}); });
it(`GET /notes/{note}/revisions`, async () => { 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()) const response = await request(app.getHttpServer())
.get('/notes/test7/revisions') .get('/notes/test7/revisions')
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(response.body.revisions).toHaveLength(1); expect(response.body).toHaveLength(1);
}); });
it(`GET /notes/{note}/revisions/{revision-id}`, async () => { 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()) const response = await request(app.getHttpServer())
.get('/notes/test8/revisions/1') .get('/notes/test8/revisions/' + revision.id)
.expect('Content-Type', /json/) .expect('Content-Type', /json/)
.expect(200); .expect(200);
expect(response.body.content).toEqual('This is a test note.'); expect(response.body.content).toEqual('This is a test note.');
}); });
it(`GET /notes/{note}/content`, async () => { 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()) const response = await request(app.getHttpServer())
.get('/notes/test9/content') .get('/notes/test9/content')
.expect(200); .expect(200);
expect(response.body).toEqual('This is a test note.'); expect(response.text).toEqual('This is a test note.');
}); });
afterAll(async () => { afterAll(async () => {

View file

@ -3622,7 +3622,7 @@ http-errors@1.7.2:
statuses ">= 1.5.0 < 2" statuses ">= 1.5.0 < 2"
toidentifier "1.0.0" toidentifier "1.0.0"
http-errors@~1.7.2: http-errors@1.7.3, http-errors@~1.7.2:
version "1.7.3" version "1.7.3"
resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06" resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.7.3.tgz#6c619e4f9c60308c38519498c14fbb10aacebb06"
integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw== integrity sha512-ZTTX0MWrsQ2ZAhA1cejAwDLycFsd7I7nVtnkT3Ol0aqodaKW+0CTZDQ1uBv5whptCnc8e8HeRRJxRs0kmm/Qfw==
@ -5944,6 +5944,16 @@ raw-body@2.4.0:
iconv-lite "0.4.24" iconv-lite "0.4.24"
unpipe "1.0.0" 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: rc@^1.2.7:
version "1.2.8" version "1.2.8"
resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.8.tgz#cd924bf5200a075b83c188cd6b9e211b7fc0d3ed"