Skip to content

Commit

Permalink
implement attachment api in backend
Browse files Browse the repository at this point in the history
  • Loading branch information
ghackenberg committed Nov 25, 2023
1 parent cba9544 commit 54b0b81
Show file tree
Hide file tree
Showing 11 changed files with 322 additions and 74 deletions.
103 changes: 74 additions & 29 deletions packages/backend/scripts/src/functions/permission.ts

Large diffs are not rendered by default.

3 changes: 2 additions & 1 deletion packages/backend/scripts/src/modules/rest.module.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common'

import { AttachmentModule } from './rest/attachments/attachment.module'
import { CommentModule } from './rest/comments/comment.module'
import { FileModule } from './rest/files/file.module'
import { IssueModule } from './rest/issues/issue.module'
Expand All @@ -13,6 +14,6 @@ import { UserModule } from './rest/users/user.module'
import { VersionModule } from './rest/versions/version.module'

@Module({
imports: [KeyModule, TokenModule, UserModule, PartModule, ProductModule, VersionModule, IssueModule, CommentModule, FileModule, MilestoneModule, MemberModule]
imports: [KeyModule, TokenModule, UserModule, PartModule, ProductModule, VersionModule, IssueModule, CommentModule, AttachmentModule, FileModule, MilestoneModule, MemberModule]
})
export class RESTModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import { Body, Controller, Delete, Get, Inject, Param, Post, Put, StreamableFile, UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'
import { FileInterceptor } from '@nestjs/platform-express'
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiExtraModels, ApiParam, ApiResponse, getSchemaPath } from '@nestjs/swagger'

import { Attachment, AttachmentAddData, AttachmentREST, AttachmentUpdateData } from 'productboard-common'

import { AttachmentService } from './attachment.service'
import { canCreateAttachmentOrFail, canDeleteAttachmentOrFail, canFindAttachmentOrFail, canReadAttachmentOrFail, canUpdateAttachmentOrFail } from '../../../functions/permission'
import { AuthorizedRequest } from '../../../request'
import { TokenOptionalGuard } from '../tokens/token.guard'

@Controller('rest/products/:productId/attachments')
@UseGuards(TokenOptionalGuard)
@ApiBearerAuth()
@ApiExtraModels(AttachmentAddData, AttachmentUpdateData)
export class AttachmentController implements AttachmentREST<string, string, Express.Multer.File> {
constructor(
private readonly service: AttachmentService,
@Inject(REQUEST)
private readonly request: AuthorizedRequest
) {}

@Get()
@ApiParam({ name: 'productId', type: 'string', required: true })
@ApiResponse({ type: [Attachment] })
async findAttachments(
@Param('productId') productId: string
): Promise<Attachment[]> {
await canFindAttachmentOrFail(this.request.user.userId, productId)
return this.service.findAttachments(productId)
}

@Post()
@UseInterceptors(FileInterceptor('file'))
@ApiParam({ name: 'productId', type: 'string', required: true})
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
data: { $ref: getSchemaPath(AttachmentAddData) },
file: { type: 'string', format: 'binary' }
},
required: ['data', 'file']
}
})
@ApiResponse({ type: Attachment })
async addAttachment(
@Param('productId') productId: string,
@Body('data') data: string,
@UploadedFile() file: Express.Multer.File
): Promise<Attachment> {
await canCreateAttachmentOrFail(this.request.user.userId, productId)
return this.addAttachment(productId, JSON.parse(data), file)
}

@Get(':attachmentId')
@ApiParam({ name: 'productId', type: 'string', required: true })
@ApiParam({ name: 'attachmentId', type: 'string', required: true })
@ApiResponse({ type: Attachment })
async getAttachment(
@Param('productId') productId: string,
@Param('attachmentId') attachmentId: string
): Promise<Attachment> {
await canReadAttachmentOrFail(this.request.user.userId, productId, attachmentId)
return this.service.getAttachment(productId, attachmentId)
}

@Get(':attachmentId/file')
@ApiParam({ name: 'productId', type: 'string', required: true })
@ApiParam({ name: 'attachmentId', type: 'string', required: true })
@ApiResponse({ type: StreamableFile })
async getAttachmentFile(
@Param('productId') productId: string,
@Param('attachmentId') attachmentId: string
): Promise<StreamableFile> {
await canReadAttachmentOrFail(this.request.user.userId, productId, attachmentId)
const attachment = await this.service.getAttachment(productId, attachmentId)
return new StreamableFile(await this.service.getAttachmentFile(productId, attachmentId), {
type: attachment.type
})
}

@Put(':attachmentId')
@UseInterceptors(FileInterceptor('file'))
@ApiParam({ name: 'productId', type: 'string', required: true })
@ApiParam({ name: 'attachmentId', type: 'string', required: true })
@ApiConsumes('multipart/form-data')
@ApiBody({
schema: {
type: 'object',
properties: {
data: { $ref: getSchemaPath(AttachmentAddData) },
file: { type: 'string', format: 'binary' }
},
required: ['data', 'file']
}
})
@ApiResponse({ type: Attachment })
async updateAttachment(
@Param('productId') productId: string,
@Param('attachmentId') attachmentId: string,
@Body('data') data: string,
@UploadedFile() file: Express.Multer.File
): Promise<Attachment> {
await canUpdateAttachmentOrFail(this.request.user.userId, productId, attachmentId)
return this.service.updateAttachment(productId, attachmentId, JSON.parse(data), file)
}

@Delete(':attachmentId')
@ApiParam({ name: 'productId', type: 'string', required: true })
@ApiParam({ name: 'attachmentId', type: 'string', required: true })
@ApiResponse({ type: Attachment })
async deleteAttachment(
@Param('productId') productId: string,
@Param('attachmentId') attachmentId: string
): Promise<Attachment> {
await canDeleteAttachmentOrFail(this.request.user.userId, productId, attachmentId)
return this.service.deleteAttachment(productId, attachmentId)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common'

import { AttachmentController } from './attachment.controller'
import { AttachmentService } from './attachment.service'

@Module({
controllers: [AttachmentController],
providers: [AttachmentService]
})
export class AttachmentModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { ReadStream, createReadStream, existsSync, mkdirSync, writeFileSync } from 'fs'

import { Inject } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'

import shortid from 'shortid'
import { IsNull } from 'typeorm'

import { Attachment, AttachmentAddData, AttachmentREST, AttachmentUpdateData } from 'productboard-common'
import { Database, convertAttachment } from 'productboard-database'

import { emitProductMessage } from '../../../functions/emit'
import { AuthorizedRequest } from '../../../request'

export class AttachmentService implements AttachmentREST<AttachmentAddData, AttachmentUpdateData, Express.Multer.File> {

constructor(
@Inject(REQUEST)
private readonly request: AuthorizedRequest
) {
if (!existsSync('./uploads')) {
mkdirSync('./uploads')
}
}

async findAttachments(productId: string): Promise<Attachment[]> {
const where = { productId, deleted: IsNull() }
const result: Attachment[] = []
for (const attachment of await Database.get().attachmentRepository.findBy(where))
result.push(convertAttachment(attachment))
return result
}

async addAttachment(productId: string, data: AttachmentAddData, file: Express.Multer.File): Promise<Attachment> {
const attachmentId = shortid()
// Save file
writeFileSync(`./uploads/${attachmentId}`, file.buffer)
// Add attachment
const created = Date.now()
const updated = created
const userId = this.request.user.userId
const attachment = await Database.get().attachmentRepository.save({ productId, attachmentId, created, updated, userId, ...data })
// Update product
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = attachment.updated
await Database.get().productRepository.save(product)
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], attachments: [attachment] })
// Return attachment
return convertAttachment(attachment)
}

async getAttachment(productId: string, attachmentId: string): Promise<Attachment> {
const attachment = await Database.get().attachmentRepository.findOneByOrFail({ productId, attachmentId })
return convertAttachment(attachment)
}

async getAttachmentFile(_productId: string, attachmentId: string): Promise<ReadStream> {
return createReadStream(`./uploads/${attachmentId}`)
}

async updateAttachment(productId: string, attachmentId: string, data: AttachmentUpdateData, file?: Express.Multer.File): Promise<Attachment> {
// Save file
writeFileSync(`./uploads/${attachmentId}`, file.buffer)
// Update attachment
const attachment = await Database.get().attachmentRepository.findOneByOrFail({ productId, attachmentId })
attachment.updated = Date.now()
attachment.type = data.type
await Database.get().attachmentRepository.save(attachment)
// Update product
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = attachment.updated
await Database.get().productRepository.save(product)
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], attachments: [attachment] })
// Return attachment
return convertAttachment(attachment)
}

async deleteAttachment(productId: string, attachmentId: string): Promise<Attachment> {
// Delete attachment
const attachment = await Database.get().attachmentRepository.findOneByOrFail({ productId, attachmentId })
attachment.deleted = Date.now()
attachment.updated = attachment.deleted
await Database.get().attachmentRepository.save(attachment)
// Update product
const product = await Database.get().productRepository.findOneBy({ productId })
product.updated = attachment.updated
await Database.get().productRepository.save(product)
// Emit changes
emitProductMessage(productId, { type: 'patch', products: [product], attachments: [attachment] })
// Return attachment
return convertAttachment(attachment)
}

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { existsSync, mkdirSync } from 'fs'

import { Inject, Injectable } from '@nestjs/common'
import { REQUEST } from '@nestjs/core'

Expand All @@ -19,11 +17,7 @@ export class IssueService implements IssueREST {
constructor(
@Inject(REQUEST)
private readonly request: AuthorizedRequest
) {
if (!existsSync('./uploads')) {
mkdirSync('./uploads')
}
}
) {}

async findIssues(productId: string) : Promise<Issue[]> {
const where = { productId, deleted: IsNull() }
Expand Down
8 changes: 2 additions & 6 deletions packages/common/src/data/attachment.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import { ApiProperty } from '@nestjs/swagger'

export class AttachmentUpdateData {
@ApiProperty()
name: string
@ApiProperty()
type: string
}
Expand All @@ -15,11 +13,9 @@ export class Attachment extends AttachmentAddData {
@ApiProperty()
productId: string
@ApiProperty()
issueId: string
@ApiProperty()
commentId: string
@ApiProperty()
attachmentId: string
@ApiProperty()
userId: string

@ApiProperty()
created: number
Expand Down
10 changes: 5 additions & 5 deletions packages/common/src/rest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,11 @@ export interface CommentREST {
}

export interface AttachmentREST<AA, AU, F> {
findAttachments(productId: string, issueId: string, commentId: string): Promise<Attachment[]>
addAttachment(productId: string, issueId: string, commentId: string, data: AA, file: F): Promise<Attachment>
getAttachment(productId: string, issueId: string, commentId: string, attachmentId: string): Promise<Attachment>
updateAttachment(productId: string, issueId: string, commentId: string, attachmentId: string, data: AU, file?: F): Promise<Attachment>
deleteAttachment(productId: string, issueId: string, commentId: string, attachmentId: string): Promise<Attachment>
findAttachments(productId: string): Promise<Attachment[]>
addAttachment(productId: string, data: AA, file: F): Promise<Attachment>
getAttachment(productId: string, attachmentId: string): Promise<Attachment>
updateAttachment(productId: string, attachmentId: string, data: AU, file: F): Promise<Attachment>
deleteAttachment(productId: string, attachmentId: string): Promise<Attachment>
}

export interface MilestoneREST {
Expand Down
4 changes: 1 addition & 3 deletions packages/database/src/convert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,13 +84,11 @@ export function convertComment(comment: CommentEntity) {
export function convertAttachment(attachment: AttachmentEntity) {
return {
productId: attachment.productId,
issueId: attachment.issueId,
commentId: attachment.commentId,
attachmentId: attachment.attachmentId,
userId: attachment.userId,
created: attachment.created,
updated: attachment.updated,
deleted: attachment.deleted,
name: attachment.name,
type: attachment.type
}
}
Expand Down
22 changes: 7 additions & 15 deletions packages/database/src/entities/attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,40 +2,32 @@ import { Column, Entity, JoinColumn, ManyToOne, PrimaryColumn } from 'typeorm'

import { Attachment } from 'productboard-common'

import { CommentEntity } from './comment'
import { IssueEntity } from './issue'
import { ProductEntity } from './product'
import { UserEntity } from './user'

@Entity()
export class AttachmentEntity extends Attachment {
@Column({ nullable: false })
override productId: string
@Column({ nullable: false })
override issueId: string
@Column({ nullable: false })
override commentId: string
@PrimaryColumn({ nullable: false })
override attachmentId: string
@Column({ nullable: false })
override userId: string

@ManyToOne(() => ProductEntity)
@JoinColumn({ name: 'productId' })
product: ProductEntity
@ManyToOne(() => IssueEntity)
@JoinColumn({ name: 'issueId' })
issue: IssueEntity
@ManyToOne(() => CommentEntity)
@JoinColumn({ name: 'commentId' })
comment: CommentEntity
@ManyToOne(() => UserEntity)
@JoinColumn({ name: 'userId' })
user: UserEntity

@Column({ nullable: false })
override created: number
@Column({ nullable: false })
override updated: number
@Column({ nullable: true })
override deleted: number

@Column({ nullable: false })
override name: string

@Column({ nullable: false })
override type: string
}
10 changes: 2 additions & 8 deletions packages/database/src/migrations/1700914190826-attachment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,16 @@ export class Attachment1700914190826 implements MigrationInterface {
name: 'attachment_entity',
columns: [
new TableColumn({ name: 'productId', type: 'text', isNullable: false }),
new TableColumn({ name: 'issueId', type: 'text', isNullable: false }),
new TableColumn({ name: 'commentId', type: 'text', isNullable: false }),
new TableColumn({ name: 'attachmentId', type: 'text', isNullable: false, isPrimary: true }),
new TableColumn({ name: 'userId', type: 'text', isNullable: false }),
new TableColumn({ name: 'created', type: 'int', isNullable: false }),
new TableColumn({ name: 'updated', type: 'int', isNullable: false }),
new TableColumn({ name: 'deleted', type: 'int', isNullable: true }),
new TableColumn({ name: 'name', type: 'string', isNullable: false }),
new TableColumn({ name: 'type', type: 'string', isNullable: false })
],
foreignKeys: [
{ columnNames: ['productId'], referencedTableName: 'product_entity', referencedColumnNames: ['productId'] },
{ columnNames: ['issueId'], referencedTableName: 'issue_entity', referencedColumnNames: ['issueId'] },
{ columnNames: ['commentId'], referencedTableName: 'comment_entity', referencedColumnNames: ['commentId'] }
],
uniques: [
{ columnNames: ['attachmentId', 'name'] }
{ columnNames: ['userId'], referencedTableName: 'user_entity', referencedColumnNames: ['userId'] }
]
}))
}
Expand Down

0 comments on commit 54b0b81

Please sign in to comment.