Extract comments functionality to packages/comments-server
This commit is contained in:
parent
2fc22f05b7
commit
155c98d000
@ -1,9 +1,6 @@
|
||||
import * as comment from '../comment'
|
||||
import * as middleware from '../middleware'
|
||||
import * as routes from '../routes'
|
||||
import * as services from '../services'
|
||||
import * as site from '../site'
|
||||
import * as story from '../story'
|
||||
import * as team from '../team'
|
||||
import * as user from '../user'
|
||||
import express from 'express'
|
||||
@ -23,9 +20,6 @@ export class Application implements IApplication {
|
||||
|
||||
readonly userService: services.IUserService
|
||||
readonly teamService: team.ITeamService
|
||||
readonly siteService: site.ISiteService
|
||||
readonly storyService: story.IStoryService
|
||||
readonly commentService: comment.ICommentService
|
||||
readonly userPermissions: user.IUserPermissions
|
||||
|
||||
readonly authenticator: middleware.Authenticator
|
||||
@ -37,10 +31,6 @@ export class Application implements IApplication {
|
||||
this.userService = new services.UserService(this.transactionManager)
|
||||
|
||||
this.teamService = new team.TeamService(this.transactionManager)
|
||||
this.siteService = new site.SiteService(this.transactionManager)
|
||||
this.storyService = new story.StoryService(
|
||||
this.transactionManager, this.siteService)
|
||||
this.commentService = new comment.CommentService(this.transactionManager)
|
||||
this.userPermissions = new user.UserPermissions(this.transactionManager)
|
||||
|
||||
this.authenticator = new middleware.Authenticator(this.userService)
|
||||
@ -61,9 +51,10 @@ export class Application implements IApplication {
|
||||
|
||||
this.configureMiddleware(router)
|
||||
this.configureRouter(router)
|
||||
this.configureApiErrorHandling(router)
|
||||
|
||||
server.use(this.config.app.context, router)
|
||||
this.configureErrorHandling(server)
|
||||
this.configureGlobalErrorHandling(server)
|
||||
return server
|
||||
}
|
||||
|
||||
@ -87,8 +78,6 @@ export class Application implements IApplication {
|
||||
}
|
||||
|
||||
protected configureRouter(router: express.Router) {
|
||||
const apiLogger = this.getApiLogger()
|
||||
|
||||
router.use('/app', new routes.LoginRoutes(
|
||||
this.userService,
|
||||
this.authenticator,
|
||||
@ -108,27 +97,14 @@ export class Application implements IApplication {
|
||||
this.userPermissions,
|
||||
this.createTransactionalRouter(),
|
||||
).handle)
|
||||
}
|
||||
|
||||
router.use('/api', new site.SiteRoutes(
|
||||
this.siteService,
|
||||
this.userPermissions,
|
||||
this.createTransactionalRouter(),
|
||||
).handle)
|
||||
|
||||
router.use('/api', new story.StoryRoutes(
|
||||
this.storyService,
|
||||
this.createTransactionalRouter(),
|
||||
).handle)
|
||||
|
||||
router.use('/api', new comment.CommentRoutes(
|
||||
this.commentService,
|
||||
this.createTransactionalRouter(),
|
||||
).handle)
|
||||
|
||||
protected configureApiErrorHandling(router: express.Router) {
|
||||
const apiLogger = this.getApiLogger()
|
||||
router.use('/api', new middleware.ErrorApiHandler(apiLogger).handle)
|
||||
}
|
||||
|
||||
protected configureErrorHandling(server: express.Application) {
|
||||
protected configureGlobalErrorHandling(server: express.Application) {
|
||||
const apiLogger = this.getApiLogger()
|
||||
server.use(new middleware.ErrorPageHandler(apiLogger).handle)
|
||||
}
|
||||
|
||||
@ -6,6 +6,7 @@ import {config} from './config'
|
||||
import {Bootstrap} from './application/Bootstrap'
|
||||
|
||||
export const bootstrap = new Bootstrap(config)
|
||||
// FIXME determine a port by parsing app url from config
|
||||
const port: string | number = process.env.PORT || 3000
|
||||
|
||||
bootstrap.listen(port)
|
||||
|
||||
@ -1,231 +0,0 @@
|
||||
import * as CommentTestUtils from './CommentTestUtils'
|
||||
import {IStory} from '@rondo/common'
|
||||
import {createSite} from '../site/SiteTestUtils'
|
||||
import {getStory} from '../story/StoryTestUtils'
|
||||
import {test} from '../test'
|
||||
|
||||
describe('comment', () => {
|
||||
|
||||
test.withDatabase()
|
||||
const t = test.request('/api')
|
||||
|
||||
let cookie!: string
|
||||
let token!: string
|
||||
let story!: IStory
|
||||
|
||||
const storyUrl = 'https://test.example.com/my/story'
|
||||
|
||||
beforeEach(async () => {
|
||||
const session = await test.registerAccount()
|
||||
cookie = session.cookie
|
||||
token = session.token
|
||||
t.setHeaders({cookie, 'x-csrf-token': token})
|
||||
|
||||
await createSite(t, 'test.example.com')
|
||||
story = await getStory(t, storyUrl)
|
||||
})
|
||||
|
||||
async function createComment() {
|
||||
return CommentTestUtils.createRootComment(t, {
|
||||
storyId: story.id,
|
||||
message: 'test',
|
||||
})
|
||||
}
|
||||
|
||||
async function createChildComment() {
|
||||
const parent = await CommentTestUtils.createRootComment(t, {
|
||||
storyId: story.id,
|
||||
message: 'this is a parent comment',
|
||||
})
|
||||
const child = await CommentTestUtils.createComment(t, {
|
||||
storyId: story.id,
|
||||
parentId: parent.id,
|
||||
message: 'this is a child comment',
|
||||
})
|
||||
expect(child.id).toBeGreaterThan(0)
|
||||
return child
|
||||
}
|
||||
|
||||
describe('GET /stories/:storyId/comments', () => {
|
||||
it('retrieves comments by story id', async () => {
|
||||
const comments = await CommentTestUtils.getComments(t, story.id)
|
||||
expect(comments.rootIds).toEqual(jasmine.any(Array))
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /stories/:storyId/comments', () => {
|
||||
it('adds a new root comment', async () => {
|
||||
const message = 'this is a comment'
|
||||
const comment = await CommentTestUtils.createRootComment(t, {
|
||||
storyId: story.id,
|
||||
message,
|
||||
})
|
||||
expect(comment.id).toBeGreaterThan(0)
|
||||
expect(comment.message).toEqual(message)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /stories/:storyId/comments/:parentId', () => {
|
||||
it('adds a new child comment', async () => {
|
||||
await createChildComment()
|
||||
const comments = await CommentTestUtils.getComments(t, story.id)
|
||||
expect(comments.rootIds).toEqual([
|
||||
jasmine.any(Number),
|
||||
])
|
||||
const id = comments.rootIds[0]
|
||||
const parent = comments.commentsById[id]
|
||||
expect(parent).toBeTruthy()
|
||||
expect(parent.message).toMatch(/parent/)
|
||||
expect(parent.childrenIds).toEqual([jasmine.any(Number)])
|
||||
const child = comments.commentsById[parent.childrenIds![0]]
|
||||
expect(child).toBeTruthy()
|
||||
expect(child.message).toMatch(/child/)
|
||||
expect(child.childrenIds).toBe(undefined)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /comments/:commentId', () => {
|
||||
|
||||
it('updates a comment', async () => {
|
||||
const comment = await createComment()
|
||||
await t.put('/comments/:commentId', {
|
||||
params: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
})
|
||||
.send({
|
||||
message: 'test2',
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
const c = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(c.message).toEqual('test2')
|
||||
|
||||
// TODO save edit history
|
||||
})
|
||||
|
||||
// it('fails to update a comment if user is not the owner')
|
||||
|
||||
// it('updates a comment if user is site moderator') // TODO later
|
||||
})
|
||||
|
||||
describe('DELETE /comments/:commentId', () => {
|
||||
it('soft deletes a comment', async () => {
|
||||
const comment = await createComment()
|
||||
await t.delete('/comments/:commentId', {
|
||||
params: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
const comment2 = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment2.message)
|
||||
.toEqual('(this message has been removed)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /comments/:commentId/vote', () => {
|
||||
it('adds a new comment vote', async () => {
|
||||
const comment = await createComment()
|
||||
await CommentTestUtils.upVote(t, comment.id)
|
||||
const c = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(c.votes).toEqual(1)
|
||||
})
|
||||
it('can only upvote once', async () => {
|
||||
const comment = await createComment()
|
||||
async function upVote() {
|
||||
return t.post('/comments/:commentId/vote', {
|
||||
params: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const responses = (await Promise.all([
|
||||
upVote(),
|
||||
upVote(),
|
||||
])).map(r => r.status)
|
||||
|
||||
expect(responses).toContain(200)
|
||||
expect(responses).toContain(400)
|
||||
|
||||
const c = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(c.votes).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /comments/:commentId/vote', () => {
|
||||
it('removes a comment vote', async () => {
|
||||
let comment = await createComment()
|
||||
await CommentTestUtils.upVote(t, comment.id)
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.votes).toEqual(1)
|
||||
await CommentTestUtils.downVote(t, comment.id)
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.votes).toEqual(0)
|
||||
})
|
||||
it('can only downvote once', async () => {
|
||||
let comment = await createComment()
|
||||
await CommentTestUtils.upVote(t, comment.id)
|
||||
await Promise.all([
|
||||
CommentTestUtils.downVote(t, comment.id),
|
||||
CommentTestUtils.downVote(t, comment.id),
|
||||
])
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.votes).toEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('POST /comments/:commentId/spam', () => {
|
||||
it('adds a new spam report', async () => {
|
||||
const comment = await createComment()
|
||||
await CommentTestUtils.markAsSpam(t, comment.id)
|
||||
const c = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(c.spams).toEqual(1)
|
||||
})
|
||||
it('can only report a spam once', async () => {
|
||||
const comment = await createComment()
|
||||
async function markAsSpam() {
|
||||
return t.post('/comments/:commentId/spam', {
|
||||
params: {
|
||||
commentId: comment.id,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const responses = (await Promise.all([
|
||||
markAsSpam(),
|
||||
markAsSpam(),
|
||||
])).map(r => r.status)
|
||||
|
||||
expect(responses).toContain(200)
|
||||
expect(responses).toContain(400)
|
||||
|
||||
const c = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(c.spams).toEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /comments/:commentId/spam', () => {
|
||||
it('removes a spam report', async () => {
|
||||
let comment = await createComment()
|
||||
await CommentTestUtils.markAsSpam(t, comment.id)
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.spams).toEqual(1)
|
||||
await CommentTestUtils.unmarkAsSpam(t, comment.id)
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.spams).toEqual(0)
|
||||
})
|
||||
it('can only remove a spam report once', async () => {
|
||||
let comment = await createComment()
|
||||
await CommentTestUtils.markAsSpam(t, comment.id)
|
||||
await Promise.all([
|
||||
CommentTestUtils.unmarkAsSpam(t, comment.id),
|
||||
CommentTestUtils.unmarkAsSpam(t, comment.id),
|
||||
])
|
||||
comment = await CommentTestUtils.getCommentById(t, comment.id)
|
||||
expect(comment.spams).toEqual(0)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -1,91 +0,0 @@
|
||||
import {AsyncRouter} from '../router'
|
||||
import {BaseRoute} from '../routes/BaseRoute'
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
import {ICommentService} from './ICommentService'
|
||||
import {ensureLoggedInApi} from '../middleware'
|
||||
|
||||
export class CommentRoutes extends BaseRoute<IAPIDef> {
|
||||
constructor(
|
||||
protected readonly commentService: ICommentService,
|
||||
protected readonly t: AsyncRouter<IAPIDef>,
|
||||
) {
|
||||
super(t)
|
||||
}
|
||||
|
||||
setup(t: AsyncRouter<IAPIDef>) {
|
||||
|
||||
t.get('/stories/:storyId/comments', async req => {
|
||||
const {storyId} = req.params
|
||||
return this.commentService.find(storyId)
|
||||
})
|
||||
|
||||
t.use(ensureLoggedInApi)
|
||||
|
||||
t.post('/stories/:storyId/comments', async req => {
|
||||
const userId = req.user!.id
|
||||
const storyId = Number(req.params.storyId)
|
||||
const {message} = req.body
|
||||
return this.commentService.saveRoot({
|
||||
message,
|
||||
storyId,
|
||||
userId,
|
||||
})
|
||||
})
|
||||
|
||||
t.post('/stories/:storyId/comments/:parentId', async req => {
|
||||
const userId = req.user!.id
|
||||
const parentId = Number(req.params.parentId)
|
||||
const storyId = Number(req.params.storyId)
|
||||
const {message} = req.body
|
||||
return this.commentService.save({
|
||||
message,
|
||||
userId,
|
||||
parentId,
|
||||
storyId,
|
||||
})
|
||||
})
|
||||
|
||||
t.get('/comments/:commentId', async req => {
|
||||
const commentId = req.params.commentId
|
||||
const comment = await this.commentService.findOne(commentId)
|
||||
// TODO return status code 404 when not found
|
||||
return comment!
|
||||
})
|
||||
|
||||
t.put('/comments/:commentId', async req => {
|
||||
const commentId = Number(req.params.commentId)
|
||||
const {message} = req.body
|
||||
return this.commentService.edit({
|
||||
id: commentId,
|
||||
message,
|
||||
userId: req.user!.id,
|
||||
})
|
||||
})
|
||||
|
||||
t.delete('/comments/:commentId', async req => {
|
||||
const {commentId} = req.params
|
||||
return this.commentService.delete(commentId, req.user!.id)
|
||||
})
|
||||
|
||||
t.post('/comments/:commentId/vote', async req => {
|
||||
const {commentId} = req.params
|
||||
return this.commentService.upVote(commentId, req.user!.id)
|
||||
})
|
||||
|
||||
t.delete('/comments/:commentId/vote', async req => {
|
||||
const {commentId} = req.params
|
||||
return this.commentService.deleteVote(commentId, req.user!.id)
|
||||
})
|
||||
|
||||
t.post('/comments/:commentId/spam', async req => {
|
||||
const {commentId} = req.params
|
||||
return this.commentService.markAsSpam(commentId, req.user!.id)
|
||||
})
|
||||
|
||||
t.delete('/comments/:commentId/spam', async req => {
|
||||
const {commentId} = req.params
|
||||
return this.commentService.unmarkAsSpam(commentId, req.user!.id)
|
||||
})
|
||||
|
||||
}
|
||||
}
|
||||
@ -1,214 +0,0 @@
|
||||
import {UniqueTransformer} from '../error/ErrorTransformer'
|
||||
import {BaseService} from '../services/BaseService'
|
||||
import {Comment} from '../entities/Comment'
|
||||
import {ICommentService} from './ICommentService'
|
||||
import {ICommentTree} from '@rondo/common'
|
||||
import {IEditCommentParams} from './IEditCommentParams'
|
||||
import {INewCommentParams} from './INewCommentParams'
|
||||
import {INewRootCommentParams} from './INewRootCommentParams'
|
||||
import {Spam} from '../entities/Spam'
|
||||
import {Validator} from '../validator'
|
||||
import {Vote} from '../entities/Vote'
|
||||
|
||||
export class CommentService extends BaseService implements ICommentService {
|
||||
|
||||
async find(storyId: number) {
|
||||
const comments = await this.getRepository(Comment).find({
|
||||
where: {
|
||||
storyId,
|
||||
},
|
||||
})
|
||||
|
||||
const commentTree: ICommentTree = {
|
||||
rootIds: [],
|
||||
commentsById: {},
|
||||
}
|
||||
comments.reduce((obj, comment) => {
|
||||
obj[comment.id] = comment
|
||||
const {parentId} = comment
|
||||
if (!parentId) {
|
||||
commentTree.rootIds.push(comment.id)
|
||||
return obj
|
||||
}
|
||||
const children =
|
||||
obj[parentId].childrenIds =
|
||||
obj[parentId].childrenIds || []
|
||||
children.push(comment.id)
|
||||
return obj
|
||||
}, commentTree.commentsById)
|
||||
|
||||
return commentTree
|
||||
}
|
||||
|
||||
async findOne(commentId: number) {
|
||||
return this.getRepository(Comment).findOne(commentId)
|
||||
}
|
||||
|
||||
async saveRoot(comment: INewRootCommentParams) {
|
||||
new Validator(comment)
|
||||
.ensure('message')
|
||||
.ensure('storyId')
|
||||
.ensure('userId')
|
||||
.throw()
|
||||
|
||||
const {
|
||||
message,
|
||||
userId,
|
||||
storyId,
|
||||
} = comment
|
||||
|
||||
return this.getRepository(Comment).save({
|
||||
message: message.trim(),
|
||||
userId,
|
||||
storyId,
|
||||
parentId: undefined,
|
||||
|
||||
votes: 0,
|
||||
spams: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async save(comment: INewCommentParams) {
|
||||
new Validator(comment)
|
||||
.ensure('message')
|
||||
.ensure('userId')
|
||||
.ensure('storyId')
|
||||
.ensure('parentId')
|
||||
.throw()
|
||||
|
||||
const {
|
||||
message,
|
||||
userId,
|
||||
storyId,
|
||||
parentId,
|
||||
} = comment
|
||||
|
||||
return this.getRepository(Comment).save({
|
||||
message: message.trim(),
|
||||
userId,
|
||||
storyId,
|
||||
parentId,
|
||||
|
||||
votes: 0,
|
||||
spams: 0,
|
||||
})
|
||||
}
|
||||
|
||||
async edit(comment: IEditCommentParams) {
|
||||
new Validator(comment)
|
||||
.ensure('id')
|
||||
.ensure('message')
|
||||
.ensure('userId')
|
||||
.throw()
|
||||
|
||||
const {id, message, userId} = comment
|
||||
|
||||
await this.getRepository(Comment)
|
||||
.update({
|
||||
id,
|
||||
userId,
|
||||
}, {
|
||||
message,
|
||||
})
|
||||
const editedComment = await this.findOne(comment.id)
|
||||
|
||||
if (!editedComment) {
|
||||
// TODO 400 or 404
|
||||
throw new Error('Comment not found')
|
||||
}
|
||||
return editedComment
|
||||
}
|
||||
|
||||
async delete(commentId: number, userId: number) {
|
||||
await this.getRepository(Comment)
|
||||
.update({
|
||||
id: commentId,
|
||||
userId,
|
||||
}, {
|
||||
message: '(this message has been removed)',
|
||||
})
|
||||
|
||||
return this.findOne(commentId)
|
||||
}
|
||||
|
||||
async upVote(commentId: number, userId: number) {
|
||||
try {
|
||||
await this.getRepository(Vote)
|
||||
.save({
|
||||
commentId,
|
||||
userId,
|
||||
})
|
||||
} catch (err) {
|
||||
UniqueTransformer.transform(err, 'Already upvoted!')
|
||||
}
|
||||
|
||||
await this.getRepository(Comment)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.where({ id: commentId })
|
||||
.set({
|
||||
votes: () => 'votes + 1',
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
async deleteVote(commentId: number, userId: number) {
|
||||
await this.getRepository(Vote)
|
||||
.delete({
|
||||
commentId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// TODO rows.affected returns undefined or SQLite driver. This is an
|
||||
// alternative query that does not depend on it.
|
||||
await this.getRepository(Comment)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.where({ id: commentId })
|
||||
.set({
|
||||
votes: () => '(select count(*) from vote where commentId = comment.id)',
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
async markAsSpam(commentId: number, userId: number) {
|
||||
try {
|
||||
await this.getRepository(Spam)
|
||||
.save({
|
||||
commentId,
|
||||
userId,
|
||||
})
|
||||
} catch (err) {
|
||||
UniqueTransformer.transform(err, 'Already marked as spam!')
|
||||
}
|
||||
|
||||
await this.getRepository(Comment)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.where({ id: commentId })
|
||||
.set({
|
||||
spams: () => 'spams + 1',
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
async unmarkAsSpam(commentId: number, userId: number) {
|
||||
await this.getRepository(Spam)
|
||||
.delete({
|
||||
commentId,
|
||||
userId,
|
||||
})
|
||||
|
||||
// TODO rows.affected returns undefined or SQLite driver. This is an
|
||||
// alternative query that does not depend on it.
|
||||
await this.getRepository(Comment)
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.where({ id: commentId })
|
||||
.set({
|
||||
spams: () => '(select count(*) from spam where commentId = comment.id)',
|
||||
})
|
||||
.execute()
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
import {RequestTester} from '../test-utils'
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
|
||||
export async function createRootComment(
|
||||
t: RequestTester<IAPIDef>,
|
||||
{storyId, message}: {
|
||||
storyId: number,
|
||||
message: string,
|
||||
},
|
||||
) {
|
||||
const response = await t
|
||||
.post('/stories/:storyId/comments', {
|
||||
params: {storyId},
|
||||
})
|
||||
.send({
|
||||
message,
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
return response.body!
|
||||
}
|
||||
|
||||
export async function createComment(
|
||||
t: RequestTester<IAPIDef>,
|
||||
{storyId, parentId, message}: {
|
||||
storyId: number,
|
||||
parentId: number,
|
||||
message: string,
|
||||
},
|
||||
) {
|
||||
const response = await t
|
||||
.post('/stories/:storyId/comments/:parentId', {
|
||||
params: {storyId, parentId},
|
||||
})
|
||||
.send({
|
||||
message,
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
return response.body!
|
||||
}
|
||||
|
||||
export async function getComments(
|
||||
t: RequestTester<IAPIDef>,
|
||||
storyId: number,
|
||||
) {
|
||||
const response = await t
|
||||
.get('/stories/:storyId/comments', {
|
||||
params: {storyId},
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
return response.body!
|
||||
}
|
||||
|
||||
export async function getCommentById(
|
||||
t: RequestTester<IAPIDef>,
|
||||
commentId: number,
|
||||
) {
|
||||
const response = await t
|
||||
.get('/comments/:commentId', {
|
||||
params: {commentId},
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
return response.body!
|
||||
}
|
||||
|
||||
export async function upVote(
|
||||
t: RequestTester<IAPIDef>,
|
||||
commentId: number,
|
||||
) {
|
||||
await t.post('/comments/:commentId/vote', {
|
||||
params: {
|
||||
commentId,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
export async function downVote(
|
||||
t: RequestTester<IAPIDef>,
|
||||
commentId: number,
|
||||
) {
|
||||
await t.delete('/comments/:commentId/vote', {
|
||||
params: {
|
||||
commentId,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
export async function markAsSpam(
|
||||
t: RequestTester<IAPIDef>,
|
||||
commentId: number,
|
||||
) {
|
||||
await t.post('/comments/:commentId/spam', {
|
||||
params: {
|
||||
commentId,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
}
|
||||
|
||||
export async function unmarkAsSpam(
|
||||
t: RequestTester<IAPIDef>,
|
||||
commentId: number,
|
||||
) {
|
||||
await t.delete('/comments/:commentId/spam', {
|
||||
params: {
|
||||
commentId,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
import {IComment, ICommentTree} from '@rondo/common'
|
||||
import {IEditCommentParams} from './IEditCommentParams'
|
||||
import {INewCommentParams} from './INewCommentParams'
|
||||
import {INewRootCommentParams} from './INewRootCommentParams'
|
||||
|
||||
export interface ICommentService {
|
||||
find(storyId: number): Promise<ICommentTree>
|
||||
|
||||
findOne(commentId: number): Promise<IComment | undefined>
|
||||
|
||||
saveRoot(comment: INewRootCommentParams): Promise<IComment>
|
||||
|
||||
save(comment: INewCommentParams): Promise<IComment>
|
||||
|
||||
edit(comment: IEditCommentParams): Promise<IComment>
|
||||
|
||||
delete(commentId: number, userId: number): Promise<IComment | undefined>
|
||||
|
||||
upVote(commentId: number, userId: number): Promise<void>
|
||||
|
||||
deleteVote(commentId: number, userId: number): Promise<void>
|
||||
|
||||
markAsSpam(commentId: number, userId: number): Promise<void>
|
||||
|
||||
unmarkAsSpam(commentId: number, userId: number): Promise<void>
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export interface IEditCommentParams {
|
||||
id: number
|
||||
message: string
|
||||
userId: number
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export interface INewCommentParams {
|
||||
message: string
|
||||
userId: number
|
||||
parentId: number
|
||||
storyId: number
|
||||
}
|
||||
@ -1,5 +0,0 @@
|
||||
export interface INewRootCommentParams {
|
||||
message: string
|
||||
userId: number
|
||||
storyId: number
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './CommentRoutes'
|
||||
export * from './CommentService'
|
||||
export * from './ICommentService'
|
||||
@ -1,32 +0,0 @@
|
||||
import {Column, Entity, Index, ManyToOne} from 'typeorm'
|
||||
import {User} from './User'
|
||||
import {Story} from './Story'
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
|
||||
@Entity()
|
||||
export class Comment extends BaseEntity {
|
||||
@Column({type: 'text'})
|
||||
message!: string
|
||||
|
||||
@ManyToOne(type => Story, story => story.comments)
|
||||
story?: Story
|
||||
|
||||
@Column()
|
||||
@Index()
|
||||
storyId!: number
|
||||
|
||||
@ManyToOne(type => User, user => user.comments)
|
||||
user?: User
|
||||
|
||||
@Column()
|
||||
userId!: number
|
||||
|
||||
@Column({nullable: true})
|
||||
parentId!: number
|
||||
|
||||
@Column()
|
||||
spams!: number
|
||||
|
||||
@Column()
|
||||
votes!: number
|
||||
}
|
||||
@ -1,31 +0,0 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, Index, ManyToOne, OneToMany} from 'typeorm'
|
||||
import {User} from './User'
|
||||
import {Story} from './Story'
|
||||
import {Team} from './Team'
|
||||
|
||||
@Entity()
|
||||
export class Site extends BaseEntity {
|
||||
@Column()
|
||||
name!: string
|
||||
|
||||
@Column({ unique: true })
|
||||
domain!: string
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
userId!: number
|
||||
|
||||
@ManyToOne(type => User, user => user.sites)
|
||||
user?: User
|
||||
|
||||
@Index()
|
||||
@Column()
|
||||
teamId!: number
|
||||
|
||||
@ManyToOne(type => Team, team => team.sites)
|
||||
team?: Team
|
||||
|
||||
@OneToMany(type => Story, story => story.site)
|
||||
stories!: Story[]
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, ManyToOne, Unique} from 'typeorm'
|
||||
import {User} from './User'
|
||||
import {Comment} from './Comment'
|
||||
|
||||
@Entity()
|
||||
@Unique('spam_userid_commentid', ['userId', 'commentId'])
|
||||
export class Spam extends BaseEntity {
|
||||
@ManyToOne(type => User)
|
||||
user?: User
|
||||
|
||||
@Column()
|
||||
userId!: number
|
||||
|
||||
@ManyToOne(type => Comment)
|
||||
comment?: Comment
|
||||
|
||||
@Column()
|
||||
commentId!: number
|
||||
}
|
||||
@ -1,19 +0,0 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, ManyToOne} from 'typeorm'
|
||||
import {Comment} from './Comment'
|
||||
import {Site} from './Site'
|
||||
|
||||
@Entity()
|
||||
export class Story extends BaseEntity {
|
||||
@Column({ unique: true })
|
||||
url!: string
|
||||
|
||||
@Column()
|
||||
siteId!: number
|
||||
|
||||
@ManyToOne(type => Site, site => site.stories)
|
||||
site?: Site
|
||||
|
||||
@ManyToOne(type => Comment, comment => comment.story)
|
||||
comments!: Comment[]
|
||||
}
|
||||
@ -1,6 +1,5 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, OneToMany, ManyToOne, Index} from 'typeorm'
|
||||
import {Site} from './Site'
|
||||
import {UserTeam} from './UserTeam'
|
||||
import {User} from './User'
|
||||
|
||||
@ -16,9 +15,6 @@ export class Team extends BaseEntity {
|
||||
@ManyToOne(type => User)
|
||||
user?: User
|
||||
|
||||
@OneToMany(type => Site, site => site.team)
|
||||
sites!: Site[]
|
||||
|
||||
@OneToMany(type => UserTeam, userTeam => userTeam.team)
|
||||
userTeams!: UserTeam[]
|
||||
}
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, OneToMany} from 'typeorm'
|
||||
import {Comment} from './Comment'
|
||||
import {Session} from './Session'
|
||||
import {Site} from './Site'
|
||||
import {UserTeam} from './UserTeam'
|
||||
import {UserEmail} from './UserEmail'
|
||||
|
||||
@ -18,12 +16,6 @@ export class User extends BaseEntity {
|
||||
@OneToMany(type => Session, session => session.user)
|
||||
sessions!: Session[]
|
||||
|
||||
@OneToMany(type => Site, site => site.user)
|
||||
sites!: Site[]
|
||||
|
||||
@OneToMany(type => Comment, comment => comment.user)
|
||||
comments!: Comment[]
|
||||
|
||||
@OneToMany(type => UserTeam, userTeam => userTeam.user)
|
||||
userTeams!: UserTeam[]
|
||||
}
|
||||
|
||||
@ -1,20 +0,0 @@
|
||||
import {BaseEntity} from './BaseEntity'
|
||||
import {Column, Entity, ManyToOne, Unique} from 'typeorm'
|
||||
import {Comment} from './Comment'
|
||||
import {User} from './User'
|
||||
|
||||
@Entity()
|
||||
@Unique('vote_userid_commentid', ['userId', 'commentId'])
|
||||
export class Vote extends BaseEntity {
|
||||
@ManyToOne(type => User)
|
||||
user?: User
|
||||
|
||||
@Column()
|
||||
userId!: number
|
||||
|
||||
@ManyToOne(type => Comment)
|
||||
comment?: Comment
|
||||
|
||||
@Column()
|
||||
commentId!: number
|
||||
}
|
||||
@ -1,3 +1,7 @@
|
||||
export * from './BaseEntity'
|
||||
export * from './Role'
|
||||
export * from './Session'
|
||||
export * from './Team'
|
||||
export * from './User'
|
||||
export * from './UserEmail'
|
||||
export * from './UserTeam'
|
||||
|
||||
2
packages/server/src/error/index.ts
Normal file
2
packages/server/src/error/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './ErrorTransformer'
|
||||
export * from './TransformedError'
|
||||
@ -1,9 +1,13 @@
|
||||
export * from './application'
|
||||
export * from './config/index'
|
||||
export * from './database'
|
||||
export * from './entities'
|
||||
export * from './error'
|
||||
export * from './logger'
|
||||
export * from './middleware'
|
||||
export * from './router'
|
||||
export * from './routes'
|
||||
export * from './services'
|
||||
export * from './session'
|
||||
export * from './user'
|
||||
export * from './validator'
|
||||
|
||||
@ -1,2 +1,3 @@
|
||||
export * from './BaseService'
|
||||
export * from './IUserService'
|
||||
export * from './UserService'
|
||||
|
||||
@ -1,6 +0,0 @@
|
||||
export interface ISiteCreateParams {
|
||||
name: string
|
||||
domain: string
|
||||
teamId: number
|
||||
userId: number
|
||||
}
|
||||
@ -1,21 +0,0 @@
|
||||
import {ISite} from '@rondo/common'
|
||||
import {ISiteCreateParams} from './ISiteCreateParams'
|
||||
import {ISiteUpdateParams} from './ISiteUpdateParams'
|
||||
|
||||
export interface ISiteService {
|
||||
create(params: ISiteCreateParams): Promise<ISite>
|
||||
|
||||
update(params: ISiteUpdateParams): Promise<ISite>
|
||||
|
||||
remove(params: {id: number, teamId: number}): Promise<void>
|
||||
|
||||
findOne(id: number, teamId: number): Promise<ISite | undefined>
|
||||
|
||||
findByUser(userId: number): Promise<ISite[]>
|
||||
|
||||
findByTeam(teamId: number): Promise<ISite[]>
|
||||
|
||||
findByDomain(domain: string): Promise<ISite | undefined>
|
||||
|
||||
// TODO add other methods
|
||||
}
|
||||
@ -1,6 +0,0 @@
|
||||
export interface ISiteUpdateParams {
|
||||
id: number
|
||||
name?: string
|
||||
domain?: string
|
||||
teamId: number
|
||||
}
|
||||
@ -1,115 +0,0 @@
|
||||
import {ITeam} from '@rondo/common'
|
||||
import {UserTeam} from '../entities/UserTeam'
|
||||
import {createTeam} from '../team/TeamTestUtils'
|
||||
import {createSite} from './SiteTestUtils'
|
||||
import {test} from '../test'
|
||||
|
||||
describe('team', () => {
|
||||
|
||||
test.withDatabase()
|
||||
const t = test.request('/api')
|
||||
|
||||
let cookie!: string
|
||||
let token!: string
|
||||
let team!: ITeam
|
||||
let userId: number
|
||||
beforeEach(async () => {
|
||||
const session = await test.registerAccount()
|
||||
cookie = session.cookie
|
||||
token = session.token
|
||||
userId = session.userId
|
||||
t.setHeaders({ cookie, 'x-csrf-token': token })
|
||||
|
||||
team = await createTeam(t, 'test')
|
||||
})
|
||||
|
||||
describe('POST /teams/:teamId/sites', () => {
|
||||
it('creates a new site', async () => {
|
||||
await createSite(t, 'test.example.com')
|
||||
})
|
||||
|
||||
describe('no team access', () => {
|
||||
beforeEach(async () => {
|
||||
await test.transactionManager
|
||||
.getRepository(UserTeam)
|
||||
.delete({
|
||||
userId,
|
||||
teamId: team.id,
|
||||
})
|
||||
})
|
||||
|
||||
it('results with 403 when user does not have team access ', async () => {
|
||||
await t
|
||||
.post('/teams/:teamId/sites', {
|
||||
params: {
|
||||
teamId: team.id,
|
||||
},
|
||||
})
|
||||
.send({
|
||||
domain: 'test.example.com',
|
||||
name: 'test',
|
||||
})
|
||||
.expect(403)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
describe('GET /teams/:teamId/sites/:id', () => {
|
||||
it('fetches a site belonging to a team', async () => {
|
||||
const site = await createSite(t, 'test.example.com')
|
||||
const response = await t.get('/teams/:teamId/sites/:id', {
|
||||
params: {
|
||||
teamId: site.teamId,
|
||||
id: site.id,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
expect(response.body!.id).toEqual(site.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('GET /teams/:teamId/sites', () => {
|
||||
it('fetches all sites belonging to a team', async () => {
|
||||
const site = await createSite(t, 'test.example.com')
|
||||
const response = await t.get('/teams/:teamId/sites', {
|
||||
params: {
|
||||
teamId: site.teamId,
|
||||
},
|
||||
})
|
||||
expect(response.body.map(s => s.id)).toContain(site.id)
|
||||
})
|
||||
})
|
||||
|
||||
describe('PUT /teams/:teamId/sites/:id', () => {
|
||||
it('updates site belonging to a team', async () => {
|
||||
const site = await createSite(t, 'test.example.com')
|
||||
const response = await t.put('/teams/:teamId/sites/:id', {
|
||||
params: {
|
||||
id: site.id,
|
||||
teamId: site.teamId,
|
||||
},
|
||||
})
|
||||
.send({
|
||||
name: site.name,
|
||||
domain: 'test2.example.com',
|
||||
})
|
||||
.expect(200)
|
||||
expect(response.body.domain).toEqual('test2.example.com')
|
||||
})
|
||||
})
|
||||
|
||||
describe('DELETE /teams/:teamId/sites/:id', () => {
|
||||
it('deletes a site', async () => {
|
||||
const site = await createSite(t, 'test.example.com')
|
||||
await t.delete('/teams/:teamId/sites/:id', {
|
||||
params: {
|
||||
id: site.id,
|
||||
teamId: site.teamId,
|
||||
},
|
||||
})
|
||||
.expect(200)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -1,91 +0,0 @@
|
||||
import {AsyncRouter} from '../router'
|
||||
import {BaseRoute} from '../routes/BaseRoute'
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
import {ISiteService} from './ISiteService'
|
||||
import {ensureLoggedInApi} from '../middleware'
|
||||
import {IUserPermissions} from '../user/IUserPermissions'
|
||||
|
||||
export class SiteRoutes extends BaseRoute<IAPIDef> {
|
||||
constructor(
|
||||
protected readonly siteService: ISiteService,
|
||||
protected readonly permissions: IUserPermissions,
|
||||
t: AsyncRouter<IAPIDef>,
|
||||
) {
|
||||
super(t)
|
||||
}
|
||||
|
||||
setup(t: AsyncRouter<IAPIDef>) {
|
||||
|
||||
t.get('/sites/:domain', async req => {
|
||||
const {domain} = req.params
|
||||
return this.siteService.findByDomain(domain)
|
||||
})
|
||||
|
||||
t.get('/teams/:teamId/sites/:id', async req => {
|
||||
const {id, teamId} = req.params
|
||||
return this.siteService.findOne(id, teamId)
|
||||
})
|
||||
|
||||
t.get('/teams/:teamId/sites', async req => {
|
||||
return this.siteService.findByTeam(req.params.teamId)
|
||||
})
|
||||
|
||||
t.use(ensureLoggedInApi)
|
||||
|
||||
// TODO do not use this one without teamId
|
||||
t.get('/my/sites', async req => {
|
||||
return this.siteService.findByUser(req.user!.id)
|
||||
})
|
||||
|
||||
t.post('/teams/:teamId/sites', async req => {
|
||||
const {name, domain} = req.body
|
||||
const {teamId} = req.params
|
||||
|
||||
await this.permissions.belongsToTeam({
|
||||
teamId,
|
||||
userId: req.user!.id,
|
||||
})
|
||||
return this.siteService.create({
|
||||
name,
|
||||
domain,
|
||||
teamId,
|
||||
userId: req.user!.id,
|
||||
})
|
||||
})
|
||||
|
||||
t.put('/teams/:teamId/sites/:id', async req => {
|
||||
const {name, domain} = req.body
|
||||
const id = Number(req.params.id)
|
||||
const teamId = Number(req.params.teamId)
|
||||
|
||||
await this.permissions.belongsToTeam({
|
||||
teamId,
|
||||
userId: req.user!.id,
|
||||
})
|
||||
|
||||
return this.siteService.update({
|
||||
id,
|
||||
teamId,
|
||||
name,
|
||||
domain,
|
||||
})
|
||||
})
|
||||
|
||||
t.delete('/teams/:teamId/sites/:id', async req => {
|
||||
const id = Number(req.params.id)
|
||||
const teamId = Number(req.params.teamId)
|
||||
|
||||
await this.permissions.belongsToTeam({
|
||||
teamId,
|
||||
userId: req.user!.id,
|
||||
})
|
||||
|
||||
return this.siteService.remove({
|
||||
id,
|
||||
teamId,
|
||||
})
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
import {BaseService} from '../services/BaseService'
|
||||
import {ISiteCreateParams} from './ISiteCreateParams'
|
||||
import {ISiteUpdateParams} from './ISiteUpdateParams'
|
||||
import {ISiteService} from './ISiteService'
|
||||
import {Site} from '../entities/Site'
|
||||
|
||||
export class SiteService extends BaseService implements ISiteService {
|
||||
async create(params: ISiteCreateParams) {
|
||||
// TODO validate params.domain
|
||||
|
||||
// TODO check site limit per user
|
||||
return this.getRepository(Site).save(params)
|
||||
}
|
||||
|
||||
async update({teamId, id, name, domain}: ISiteUpdateParams) {
|
||||
// TODO validate params.domain
|
||||
|
||||
await this.getRepository(Site)
|
||||
.update({
|
||||
id,
|
||||
teamId,
|
||||
}, {
|
||||
name,
|
||||
domain,
|
||||
})
|
||||
|
||||
return (await this.findOne(id, teamId))!
|
||||
}
|
||||
|
||||
async remove({id, teamId}: {id: number, teamId: number}) {
|
||||
await this.getRepository(Site)
|
||||
.delete({
|
||||
id,
|
||||
teamId,
|
||||
})
|
||||
}
|
||||
|
||||
async findOne(id: number, teamId: number) {
|
||||
return this.getRepository(Site).findOne({
|
||||
where: {
|
||||
id,
|
||||
teamId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findByDomain(domain: string) {
|
||||
return this.getRepository(Site).findOne({
|
||||
where: {
|
||||
domain,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async findByUser(userId: number) {
|
||||
return this.getRepository(Site).find({
|
||||
where: { userId },
|
||||
})
|
||||
}
|
||||
|
||||
async findByTeam(teamId: number) {
|
||||
return this.getRepository(Site).find({
|
||||
where: { teamId },
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
import {RequestTester} from '../test-utils'
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
import {createTeam} from '../team/TeamTestUtils'
|
||||
|
||||
export async function createSite(t: RequestTester<IAPIDef>, domain: string) {
|
||||
const team = await createTeam(t, 'test')
|
||||
const response = await t
|
||||
.post('/teams/:teamId/sites', {
|
||||
params: {
|
||||
teamId: team.id,
|
||||
},
|
||||
})
|
||||
.send({
|
||||
domain,
|
||||
name: 'test-site',
|
||||
})
|
||||
.expect(200)
|
||||
expect(response.body.id).toBeTruthy()
|
||||
return response.body
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './ISiteService'
|
||||
export * from './SiteRoutes'
|
||||
export * from './SiteService'
|
||||
@ -1,7 +0,0 @@
|
||||
import {Story} from '../entities/Story'
|
||||
|
||||
export interface IStoryService {
|
||||
findOne(url: string): Promise<Story | undefined>
|
||||
|
||||
// TODO add other methods
|
||||
}
|
||||
@ -1,58 +0,0 @@
|
||||
import {ISite} from '@rondo/common'
|
||||
import {createSite} from '../site/SiteTestUtils'
|
||||
import {getStory} from './StoryTestUtils'
|
||||
import {test} from '../test'
|
||||
|
||||
describe('story', () => {
|
||||
|
||||
test.withDatabase()
|
||||
const t = test.request('/api')
|
||||
|
||||
let cookie!: string
|
||||
let token!: string
|
||||
let site!: ISite
|
||||
beforeEach(async () => {
|
||||
const session = await test.registerAccount()
|
||||
cookie = session.cookie
|
||||
token = session.token
|
||||
t.setHeaders({ cookie, 'x-csrf-token': token })
|
||||
|
||||
site = await createSite(t, 'test.example.com')
|
||||
})
|
||||
|
||||
const invalidUrl = 'https://invalid.example.com/test'
|
||||
const validUrl = 'https://test.example.com/test'
|
||||
|
||||
describe('/stories/by-url', () => {
|
||||
it('returns undefined when a site is not configured', async () => {
|
||||
const response = await t
|
||||
.get('/stories/by-url', {
|
||||
query: { url: invalidUrl },
|
||||
})
|
||||
.expect(200)
|
||||
expect(response.body).toEqual('')
|
||||
})
|
||||
|
||||
it('creates a story when it does not exist', async () => {
|
||||
const story = await getStory(t, validUrl)
|
||||
expect(story.id).toBeTruthy()
|
||||
expect(story.siteId).toEqual(site.id)
|
||||
})
|
||||
|
||||
it('retrieves existing story after it is created', async () => {
|
||||
const story1 = await getStory(t, validUrl)
|
||||
const story2 = await getStory(t, validUrl)
|
||||
expect(story1.id).toBeTruthy()
|
||||
expect(story1).toEqual(story2)
|
||||
})
|
||||
|
||||
it('prevents unique exceptions', async () => {
|
||||
const p1 = getStory(t, validUrl)
|
||||
const p2 = getStory(t, validUrl)
|
||||
const [story1, story2] = await Promise.all([p1, p2])
|
||||
expect(story1.id).toBeTruthy()
|
||||
expect(story1).toEqual(story2)
|
||||
})
|
||||
})
|
||||
|
||||
})
|
||||
@ -1,23 +0,0 @@
|
||||
import {AsyncRouter} from '../router'
|
||||
import {BaseRoute} from '../routes/BaseRoute'
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
import {IStoryService} from './IStoryService'
|
||||
|
||||
export class StoryRoutes extends BaseRoute<IAPIDef> {
|
||||
constructor(
|
||||
protected readonly storyService: IStoryService,
|
||||
protected readonly t: AsyncRouter<IAPIDef>,
|
||||
) {
|
||||
super(t)
|
||||
}
|
||||
|
||||
setup(t: AsyncRouter<IAPIDef>) {
|
||||
|
||||
t.get('/stories/by-url', async req => {
|
||||
const {url} = req.query
|
||||
return this.storyService.findOne(url)
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@ -1,50 +0,0 @@
|
||||
import URL from 'url'
|
||||
import {BaseService} from '../services/BaseService'
|
||||
import {ISiteService} from '../site/ISiteService'
|
||||
import {IStoryService} from './IStoryService'
|
||||
import {ITransactionManager} from '../database/ITransactionManager'
|
||||
import {Story} from '../entities/Story'
|
||||
import {UniqueTransformer} from '../error/ErrorTransformer'
|
||||
|
||||
export class StoryService extends BaseService implements IStoryService {
|
||||
constructor(
|
||||
transactionManager: ITransactionManager,
|
||||
protected readonly siteService: ISiteService,
|
||||
) {
|
||||
super(transactionManager)
|
||||
}
|
||||
|
||||
async findOne(url: string) {
|
||||
const storyRepo = this.getRepository(Story)
|
||||
const story = await storyRepo.findOne({
|
||||
where: {url},
|
||||
})
|
||||
if (story) {
|
||||
return story
|
||||
}
|
||||
|
||||
const domain = URL.parse(url).hostname
|
||||
const site = await this.siteService.findByDomain(domain!)
|
||||
|
||||
if (!site) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
try {
|
||||
return await storyRepo.save({
|
||||
url,
|
||||
siteId: site.id,
|
||||
})
|
||||
} catch (err) {
|
||||
// throw if not a unique constraint error
|
||||
UniqueTransformer.throwIfNotMatch(err)
|
||||
|
||||
// This could happen if there are two concurrent requests coming in at
|
||||
// the same time, and they both cannot find the story, then decide to
|
||||
// create a record.
|
||||
return await storyRepo.findOne({
|
||||
where: {url},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +0,0 @@
|
||||
import {IAPIDef} from '@rondo/common'
|
||||
import {RequestTester} from '../test-utils'
|
||||
|
||||
export async function getStory(t: RequestTester<IAPIDef>, url: string) {
|
||||
const response = await t
|
||||
.get('/stories/by-url', {
|
||||
query: {url},
|
||||
})
|
||||
.expect(200)
|
||||
|
||||
return response.body!
|
||||
}
|
||||
@ -1,3 +0,0 @@
|
||||
export * from './StoryRoutes'
|
||||
export * from './IStoryService'
|
||||
export * from './StoryService'
|
||||
Loading…
x
Reference in New Issue
Block a user