diff --git a/prisma/migrations/20231011083919_item_starred/migration.sql b/prisma/migrations/20231011083919_item_starred/migration.sql new file mode 100644 index 0000000..cffde7a --- /dev/null +++ b/prisma/migrations/20231011083919_item_starred/migration.sql @@ -0,0 +1,25 @@ +-- CreateTable +CREATE TABLE "ItemStarred" ( + "id" SERIAL NOT NULL, + "itemId" INTEGER NOT NULL, + "userId" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "ItemStarred_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "ItemStarred_itemId_idx" ON "ItemStarred"("itemId"); + +-- CreateIndex +CREATE INDEX "ItemStarred_userId_idx" ON "ItemStarred"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "ItemStarred_itemId_userId_key" ON "ItemStarred"("itemId", "userId"); + +-- AddForeignKey +ALTER TABLE "ItemStarred" ADD CONSTRAINT "ItemStarred_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ItemStarred" ADD CONSTRAINT "ItemStarred_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c88c72d..ba1527b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -20,6 +20,7 @@ model User { sessions UserSession[] Item Item[] ItemSharing ItemSharing[] + ItemStarred ItemStarred[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -57,6 +58,7 @@ model Item { ItemSharing ItemSharing[] ItemShortcut ItemShortcut? @relation("shortcutItem") LinkedItemShortcut ItemShortcut[] @relation("linkedItem") + ItemStarred ItemStarred[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -118,3 +120,19 @@ model ItemShortcut { @@index([linkedItemId]) } + +model ItemStarred { + id Int @id @default(autoincrement()) + itemId Int + userId Int + + item Item @relation(fields: [itemId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([itemId, userId]) + @@index([itemId]) + @@index([userId]) +} diff --git a/src/locales/da.json b/src/locales/da.json index ae33ad5..de57f76 100644 --- a/src/locales/da.json +++ b/src/locales/da.json @@ -111,6 +111,10 @@ "shortcut": { "notFound": "Genvej blev ikke fundet" }, + "starred": { + "notFound": "Stjernemarkeringen blev ikke fundet", + "alreadyExists": "Stjernemarkeringen findes allerede" + }, "notFound": "Item ikke fundet" }, "folder": { @@ -163,5 +167,19 @@ "parentId": { "type": "Parent id skal være et tal" } + }, + "starred": { + "id": { + "required": "id er påkrævet", + "type": "id skal være et tal" + }, + "userId": { + "required": "userId er påkrævet", + "type": "userId skal være et tal" + }, + "itemId": { + "required": "itemId er påkrævet", + "type": "itemId skal være et tal" + } } } diff --git a/src/locales/en.json b/src/locales/en.json index 72beb8d..7e2345f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -111,6 +111,10 @@ "shortcut": { "notFound": "Shortcut not found" }, + "starred": { + "notFound": "Starred not found", + "alreadyExists": "Starred already exists" + }, "notFound": "Item not found" }, "folder": { @@ -163,5 +167,19 @@ "parentId": { "type": "Parent id must be a number" } + }, + "starred": { + "id": { + "required": "id is required", + "type": "id must be a number" + }, + "userId": { + "required": "userId is required", + "type": "userId must be a number" + }, + "itemId": { + "required": "itemId is required", + "type": "itemId must be a number" + } } } diff --git a/src/modules/item/__test__/item.read.test.ts b/src/modules/item/__test__/item.read.test.ts index d6870d0..b60fd2e 100644 --- a/src/modules/item/__test__/item.read.test.ts +++ b/src/modules/item/__test__/item.read.test.ts @@ -96,6 +96,7 @@ describe('GET /api/item/:parentId', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -107,6 +108,7 @@ describe('GET /api/item/:parentId', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -118,6 +120,7 @@ describe('GET /api/item/:parentId', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -128,6 +131,7 @@ describe('GET /api/item/:parentId', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, ]); }); diff --git a/src/modules/item/__test__/item.root.test.ts b/src/modules/item/__test__/item.root.test.ts index 7af6645..79f0283 100644 --- a/src/modules/item/__test__/item.root.test.ts +++ b/src/modules/item/__test__/item.root.test.ts @@ -89,6 +89,7 @@ describe('GET /api/item', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -100,6 +101,7 @@ describe('GET /api/item', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -111,6 +113,7 @@ describe('GET /api/item', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -122,6 +125,7 @@ describe('GET /api/item', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, { id: expect.any(Number), @@ -132,6 +136,7 @@ describe('GET /api/item', () => { createdAt: expect.any(String), deletedAt: null, updatedAt: expect.any(String), + isStarred: false, }, ]); }); diff --git a/src/modules/item/__test__/item.service.test.ts b/src/modules/item/__test__/item.service.test.ts index 2bae2f2..c4b542f 100644 --- a/src/modules/item/__test__/item.service.test.ts +++ b/src/modules/item/__test__/item.service.test.ts @@ -82,6 +82,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -94,6 +95,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -106,6 +108,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -118,6 +121,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, ]); }); @@ -247,6 +251,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -259,6 +264,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -271,6 +277,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -283,6 +290,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -295,6 +303,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -307,6 +316,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -319,6 +329,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, ]; const expectedSharredUser = [ @@ -333,6 +344,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -345,6 +357,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -357,6 +370,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, { id: expect.any(Number), @@ -369,6 +383,7 @@ describe('ItemService', () => { createdAt: expect.any(Date), deletedAt: null, updatedAt: expect.any(Date), + isStarred: false, }, ]; diff --git a/src/modules/item/__test__/item.starred.test.ts b/src/modules/item/__test__/item.starred.test.ts new file mode 100644 index 0000000..7b04dd2 --- /dev/null +++ b/src/modules/item/__test__/item.starred.test.ts @@ -0,0 +1,208 @@ +import { User } from '@prisma/client'; +import UserService from '../../auth/user.service'; +import AuthService from '../../auth/auth.service'; +import StarredService from '../starred/starred.service'; +import FolderService from '../folder/folder.service'; +import BlobService from '../blob/blob.service'; +import SharingService from '../sharing/sharing.service'; +import ItemService from '../item.service'; + +describe('GET /api/item/starred', () => { + let userService: UserService; + let authService: AuthService; + let starredService: StarredService; + let folderService: FolderService; + let blobService: BlobService; + let sharingService: SharingService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + starredService = new StarredService(); + folderService = new FolderService(); + blobService = new BlobService(); + sharingService = new SharingService(new ItemService()); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe@biden.com', + password: '1234', + }); + otherUser = await userService.createUser({ + name: 'Joe Biden the 2nd', + email: 'joe2@biden.com', + password: '4321', + }); + }); + + it('should return status 400, when item starred browse is empty', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toStrictEqual([]); + }); + + it('should return status 200 and items', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder1 = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const folder2 = await folderService.createFolder({ + name: 'Folder2', + ownerId: user.id, + parentId: null, + color: '#79BC61', + }); + + const blob = await blobService.createBlob({ + mimeType: 'text/plain', + name: 'test1.txt', + ownerId: user.id, + parentId: null, + blobUrl: 'https://example.com/test1.txt', + }); + + const blob2 = await blobService.createBlob({ + mimeType: 'text/plain', + name: 'test2.txt', + ownerId: otherUser.id, + parentId: null, + blobUrl: 'https://example.com/test2.txt', + }); + + await starredService.createStarred({ + itemId: folder1.id, + userId: user.id, + }); + + await starredService.createStarred({ + itemId: folder2.id, + userId: user.id, + }); + + await starredService.createStarred({ + itemId: blob.id, + userId: user.id, + }); + + await sharingService.createSharing( + { + itemId: blob2.id, + userId: user.id, + }, + otherUser.id, + ); + + await starredService.createStarred({ + itemId: blob2.id, + userId: user.id, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual([ + { + id: folder1.id, + name: 'Folder1', + color: '#78BC61', + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: true, + }, + { + id: blob.id, + name: 'test1.txt', + blobUrl: 'https://example.com/test1.txt', + parentId: null, + ownerId: user.id, + mimeType: 'text/plain', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: true, + }, + { + id: blob2.id, + name: 'test2.txt', + blobUrl: 'https://example.com/test2.txt', + parentId: null, + ownerId: otherUser.id, + mimeType: 'text/plain', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: true, + }, + { + id: folder2.id, + name: 'Folder2', + color: '#79BC61', + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.folder', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + isStarred: true, + }, + ]); + }); + + it('should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + await starredService.createStarred({ + itemId: folder.id, + userId: user.id, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/item/starred', + headers: { + authorization: 'invalid_access_token!!!', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); +}); diff --git a/src/modules/item/index.ts b/src/modules/item/index.ts index 5cc64d1..3490800 100644 --- a/src/modules/item/index.ts +++ b/src/modules/item/index.ts @@ -7,6 +7,7 @@ import folder from './folder'; import sharing from './sharing'; import itemRoute from './item.route'; import shortcut from './shortcut'; +import starred from './starred'; import { itemSchemas } from './item.schema'; export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPluginOptions) => { @@ -15,6 +16,7 @@ export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPl await fastify.register(folder, getOptionsWithPrefix(options, '/folder')); await fastify.register(sharing, getOptionsWithPrefix(options, '/sharing')); await fastify.register(shortcut, getOptionsWithPrefix(options, '/shortcut')); + await fastify.register(starred, getOptionsWithPrefix(options, '/starred')); await fastify.register(itemRoute, getOptionsWithPrefix(options, '/item')); for (const schema of itemSchemas) { diff --git a/src/modules/item/item.controller.ts b/src/modules/item/item.controller.ts index 77e7d2a..33e847f 100644 --- a/src/modules/item/item.controller.ts +++ b/src/modules/item/item.controller.ts @@ -12,6 +12,17 @@ export default class ItemController { this.accessService = accessService; } + public async itemStarredHandler(request: FastifyRequest, reply: FastifyReply) { + try { + const starred = await this.itemService.getStarredItemsByUserId(request.user.sub); + + return reply.code(200).send(starred); + } catch (e) { + /* istanbul ignore next */ + return reply.badRequest(); + } + } + public async itemRootHandler(request: FastifyRequest, reply: FastifyReply) { try { const items = await this.itemService.getByOwnerIdAndParentId(request.user.sub, null); diff --git a/src/modules/item/item.route.ts b/src/modules/item/item.route.ts index 2587426..2cfbe65 100644 --- a/src/modules/item/item.route.ts +++ b/src/modules/item/item.route.ts @@ -11,6 +11,25 @@ export default async (fastify: FastifyInstance) => { new AccessService(itemService, new SharingService(itemService)), ); + fastify.get( + '/starred', + { + schema: { + tags: ['Item'], + response: { + 200: { $ref: 'itemsResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + itemController.itemStarredHandler.bind(itemController), + ); + fastify.get( '/', { diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts index 1d4eb2a..b959235 100644 --- a/src/modules/item/item.schema.ts +++ b/src/modules/item/item.schema.ts @@ -4,6 +4,7 @@ import { ItemFolder as prismaItemFolderType, ItemDocs as prismaItemDocsType, ItemShortcut as prismaItemShortcutType, + ItemStarred as prismaItemStarredType, } from '@prisma/client'; import { FromSchema } from 'json-schema-to-ts'; @@ -11,13 +12,15 @@ export type Item = prismaItemType; export type ItemPrismaProperties = Item & { ItemBlob: prismaItemBlobType | null } & { ItemFolder: prismaItemFolderType | null; -} & { ItemDocs: prismaItemDocsType | null } & { ItemShortcut: prismaItemShortcutType | null }; +} & { ItemDocs: prismaItemDocsType | null } & { ItemShortcut: prismaItemShortcutType | null } & { + ItemStarred: prismaItemStarredType[]; +}; export type ItemWithProperties = Item & Omit, 'id' | 'itemId'> & Omit, 'id' | 'itemId'> & Omit, 'id' | 'itemId'> & - Omit, 'id' | 'itemId'>; + Omit, 'id' | 'itemId'> & { isStarred: boolean }; export type CreateItem = Omit; @@ -66,6 +69,9 @@ const itemsResponseSchema = { parentId: { type: ['number', 'null'], }, + isStarred: { + type: 'boolean', + }, mimeType: { type: 'string', }, diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index f051388..ebc9a98 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -43,6 +43,11 @@ export default class ItemService { ItemFolder: true, ItemDocs: true, ItemShortcut: true, + ItemStarred: { + where: { + userId: ownerId, + }, + }, }, }); @@ -75,6 +80,31 @@ export default class ItemService { return returnItems; } + public async getStarredItemsByUserId(userId: number): Promise { + const items = await prisma.item.findMany({ + where: { + ItemStarred: { + some: { + userId: userId, + }, + }, + }, + include: { + ItemBlob: true, + ItemFolder: true, + ItemDocs: true, + ItemShortcut: true, + ItemStarred: { + where: { + userId: userId, + }, + }, + }, + }); + + return this.formatItems(items); + } + public async getAllOwnedAndSharredItemsByParentIdAndUserId( userId: number, parentId: number | null, @@ -100,6 +130,11 @@ export default class ItemService { ItemFolder: true, ItemDocs: true, ItemShortcut: true, + ItemStarred: { + where: { + userId: userId, + }, + }, }, }); @@ -108,7 +143,8 @@ export default class ItemService { private formatItems(items: ItemPrismaProperties[]): ItemWithProperties[] { return items.map((element) => { - const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ...strippedElement } = element; + const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ItemStarred, ...strippedElement } = + element; return { ...ItemBlob, @@ -116,6 +152,7 @@ export default class ItemService { ...ItemDocs, ...ItemShortcut, ...strippedElement, + isStarred: ItemStarred.length > 0, }; }); } diff --git a/src/modules/item/starred/__test__/add.test.ts b/src/modules/item/starred/__test__/add.test.ts new file mode 100644 index 0000000..4faf127 --- /dev/null +++ b/src/modules/item/starred/__test__/add.test.ts @@ -0,0 +1,249 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import FolderService from '../../folder/folder.service'; +import StarredService from '../../starred/starred.service'; +import SharingService from '../../sharing/sharing.service'; +import ItemService from '../../item.service'; + +describe('POST /api/starred', () => { + let userService: UserService; + let authService: AuthService; + let folderService: FolderService; + let starredService: StarredService; + let sharingService: SharingService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + folderService = new FolderService(); + starredService = new StarredService(); + sharingService = new SharingService(new ItemService()); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe@biden.com', + password: '1234', + }); + otherUser = await userService.createUser({ + name: 'Joe Biden the 2nd', + email: 'joe2@biden.com', + password: '4321', + }); + }); + + it('should return status 200 and item', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + itemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: expect.any(Number), + userId: user.id, + itemId: folder.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should return status 200 and item, when starred', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + await sharingService.createSharing( + { + itemId: folder.id, + userId: user.id, + }, + otherUser.id, + ); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + itemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: expect.any(Number), + itemId: folder.id, + userId: user.id, + createdAt: expect.any(String), + updatedAt: expect.any(String), + }); + }); + + it('should return status 400, when starring an item twice', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + await starredService.createStarred({ + itemId: folder.id, + userId: user.id, + }); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + itemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Starred already exists'], + }, + statusCode: 400, + }); + }); + + it('should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'invalid_access_token!!!', + }, + payload: { + itemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when item id is provided, but no access to item', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + itemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when itemid is not provided', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: {}, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + _: ['itemId is required'], + }, + statusCode: 400, + }); + }); + + it("should return status 400, when item id isn't a number", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/starred', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + itemId: 'id', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + itemId: ['itemId must be a number'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/starred/__test__/delete.test.ts b/src/modules/item/starred/__test__/delete.test.ts new file mode 100644 index 0000000..bade754 --- /dev/null +++ b/src/modules/item/starred/__test__/delete.test.ts @@ -0,0 +1,211 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import StarredService from '../starred.service'; +import FolderService from '../../folder/folder.service'; +import SharingService from '../../sharing/sharing.service'; +import ItemService from '../../item.service'; + +describe('DELETE /api/starred/:id', () => { + let userService: UserService; + let authService: AuthService; + let starredService: StarredService; + let folderService: FolderService; + let sharingService: SharingService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + starredService = new StarredService(); + folderService = new FolderService(); + sharingService = new SharingService(new ItemService()); + + user = await userService.createUser({ + name: 'Joe Biden the 1st', + email: 'joe@biden.com', + password: '1234', + }); + otherUser = await userService.createUser({ + name: 'Joe Biden the 2nd', + email: 'joe2@biden.com', + password: '4321', + }); + }); + + it('should delete starred', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const starred = await starredService.createStarred({ + itemId: folder.id, + userId: user.id, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/' + starred.itemId, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + + await expect(starredService.getByItemIdAndUserId(folder.id, user.id)).rejects.toThrowError(); + }); + + it('should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const starred = await starredService.createStarred({ + itemId: folder.id, + userId: user.id, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/' + starred.itemId, + headers: { + authorization: 'invalid_access_token!!!', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 400, since starred id is not accessible to others', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const starred = await starredService.createStarred({ + itemId: folder.id, + userId: otherUser.id, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/' + starred.itemId, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Starred not found'], + }, + statusCode: 400, + }); + }); + + it('should return status 200, when removing a starring, to an item you no longer have access to', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const sharing = await sharingService.createSharing( + { + itemId: folder.id, + userId: user.id, + }, + otherUser.id, + ); + + await starredService.createStarred({ + itemId: folder.id, + userId: user.id, + }); + + await sharingService.deleteSharingByIdAndUserId(sharing.id, user.id); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/' + folder.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + + await expect(starredService.getByItemIdAndUserId(folder.id, user.id)).rejects.toThrowError(); + }); + + it("should return status 400, when starred id isn't a number", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/invalid_id', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + id: ['id must be a number'], + }, + statusCode: 400, + }); + }); + + it("should return status 400, when starred with id doesn't exist", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/starred/1234', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Starred not found'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/starred/index.ts b/src/modules/item/starred/index.ts new file mode 100644 index 0000000..7cb80fc --- /dev/null +++ b/src/modules/item/starred/index.ts @@ -0,0 +1,13 @@ +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +import fastifyPlugin from 'fastify-plugin'; +import { starredSchemas } from './starred.schema'; +import starredRoute from './starred.route'; + +export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPluginOptions) => { + for (const schema of starredSchemas) { + fastify.addSchema(schema); + } + + await fastify.register(starredRoute, options); +}); diff --git a/src/modules/item/starred/starred.controller.ts b/src/modules/item/starred/starred.controller.ts new file mode 100644 index 0000000..3b0df06 --- /dev/null +++ b/src/modules/item/starred/starred.controller.ts @@ -0,0 +1,65 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { AddInput, DeleteInput } from './starred.schema'; +import StarredService from './starred.service'; +import AccessService from '../sharing/access.service'; + +export default class StarredController { + private starredService: StarredService; + private accessService: AccessService; + + constructor(starredService: StarredService, accessService: AccessService) { + this.starredService = starredService; + this.accessService = accessService; + } + + public async addHandler( + request: FastifyRequest<{ + Body: AddInput; + }>, + reply: FastifyReply, + ) { + try { + if (!(await this.accessService.hasAccessToItem(request.body.itemId, request.user.sub))) { + return reply.unauthorized(); + } + + const starred = await this.starredService.createStarred({ + itemId: request.body.itemId, + userId: request.user.sub, + }); + + return reply.code(200).send(starred); + } catch (e) { + if (e instanceof Error) { + return reply.badRequest(request.i18n.t(e.message)); + } + + /* istanbul ignore next */ + return reply.badRequest(); + } + } + + public async deleteHandler( + request: FastifyRequest<{ + Params: DeleteInput; + }>, + reply: FastifyReply, + ) { + try { + const starred = await this.starredService.getByItemIdAndUserId( + request.params.id, + request.user.sub, + ); + + await this.starredService.deleteStarredById(starred.id); + return reply.code(204).send(); + } catch (e) { + if (e instanceof Error) { + return reply.badRequest(request.i18n.t(e.message)); + } + + /* istanbul ignore next */ + return reply.badRequest(); + } + } +} diff --git a/src/modules/item/starred/starred.route.ts b/src/modules/item/starred/starred.route.ts new file mode 100644 index 0000000..05808e3 --- /dev/null +++ b/src/modules/item/starred/starred.route.ts @@ -0,0 +1,52 @@ +import { FastifyInstance } from 'fastify'; +import StarredController from './starred.controller'; +import StarredService from './starred.service'; +import AccessService from '../sharing/access.service'; +import ItemService from '../item.service'; +import SharingService from '../sharing/sharing.service'; + +export default async (fastify: FastifyInstance) => { + const itemService = new ItemService(); + const starredService = new StarredService(); + const starredController = new StarredController( + starredService, + new AccessService(itemService, new SharingService(itemService)), + ); + + fastify.post( + '/', + { + schema: { + tags: ['Starred'], + body: { $ref: 'addStarredSchema' }, + response: { + 200: { $ref: 'addStarredResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + starredController.addHandler.bind(starredController), + ); + + fastify.delete( + '/:id', + { + schema: { + tags: ['Starred'], + params: { $ref: 'deleteStarredSchema' }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + starredController.deleteHandler.bind(starredController), + ); +}; diff --git a/src/modules/item/starred/starred.schema.ts b/src/modules/item/starred/starred.schema.ts new file mode 100644 index 0000000..926acbc --- /dev/null +++ b/src/modules/item/starred/starred.schema.ts @@ -0,0 +1,76 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { Item } from '../item.schema'; +import { ItemStarred as prismaItemStarredType } from '@prisma/client'; + +const addStarredSchema = { + $id: 'addStarredSchema', + type: 'object', + properties: { + itemId: { + type: 'number', + errorMessage: { + type: 'starred.itemId.type', + }, + }, + }, + required: ['itemId'], + errorMessage: { + required: { + itemId: 'starred.itemId.required', + }, + }, +} as const; + +const addStarredResponseSchema = { + $id: 'addStarredResponseSchema', + type: 'object', + properties: { + id: { + type: 'number', + }, + userId: { + type: 'number', + }, + itemId: { + type: 'number', + }, + createdAt: { + type: 'string', + }, + updatedAt: { + type: 'string', + }, + }, +} as const; + +const deleteStarredSchema = { + $id: 'deleteStarredSchema', + type: 'object', + properties: { + id: { + type: 'number', + errorMessage: { + type: 'starred.id.type', + }, + }, + }, + required: ['id'], + errorMessage: { + required: { + id: 'starred.id.required', + }, + }, +} as const; + +export type AddInput = FromSchema; +export type DeleteInput = FromSchema; + +export type AddStarred = { + itemId: number; + userId: number; +}; + +export type ItemStarred = prismaItemStarredType & { item: Item }; +export type Starred = prismaItemStarredType; + +export const starredSchemas = [addStarredSchema, addStarredResponseSchema, deleteStarredSchema]; diff --git a/src/modules/item/starred/starred.service.ts b/src/modules/item/starred/starred.service.ts new file mode 100644 index 0000000..aa9060f --- /dev/null +++ b/src/modules/item/starred/starred.service.ts @@ -0,0 +1,58 @@ +import { prisma } from '../../../plugins/prisma'; +import { Starred, AddStarred } from './starred.schema'; + +export default class StarredService { + public async createStarred(input: AddStarred): Promise { + try { + await this.getByItemIdAndUserId(input.itemId, input.userId); + + throw new Error('item.starred.alreadyExists'); + } catch (e) { + if (e instanceof Error && e.message === 'item.starred.alreadyExists') { + throw e; + } + + const itemStarred = await prisma.itemStarred.create({ + data: { + item: { + connect: { + id: input.itemId, + }, + }, + user: { + connect: { + id: input.userId, + }, + }, + }, + }); + + return itemStarred; + } + } + + public async getByItemIdAndUserId(itemId: number, userId: number): Promise { + const itemStarred = await prisma.itemStarred.findUnique({ + where: { + itemId_userId: { + itemId: itemId, + userId: userId, + }, + }, + }); + + if (!itemStarred) { + throw new Error('item.starred.notFound'); + } + + return itemStarred; + } + + public async deleteStarredById(id: number): Promise { + await prisma.item.delete({ + where: { + id: id, + }, + }); + } +}