diff --git a/prisma/migrations/20231009115549_item_shortcut/migration.sql b/prisma/migrations/20231009115549_item_shortcut/migration.sql new file mode 100644 index 0000000..1093659 --- /dev/null +++ b/prisma/migrations/20231009115549_item_shortcut/migration.sql @@ -0,0 +1,20 @@ +-- CreateTable +CREATE TABLE "ItemShortcut" ( + "id" SERIAL NOT NULL, + "itemId" INTEGER NOT NULL, + "linkedItemId" INTEGER NOT NULL, + + CONSTRAINT "ItemShortcut_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "ItemShortcut_itemId_key" ON "ItemShortcut"("itemId"); + +-- CreateIndex +CREATE INDEX "ItemShortcut_linkedItemId_idx" ON "ItemShortcut"("linkedItemId"); + +-- AddForeignKey +ALTER TABLE "ItemShortcut" ADD CONSTRAINT "ItemShortcut_itemId_fkey" FOREIGN KEY ("itemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "ItemShortcut" ADD CONSTRAINT "ItemShortcut_linkedItemId_fkey" FOREIGN KEY ("linkedItemId") REFERENCES "Item"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3637c60..c88c72d 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -48,13 +48,15 @@ model Item { ownerId Int parentId Int? - owner User @relation(fields: [ownerId], references: [id]) - parentItem Item? @relation("ItemToItem", fields: [parentId], references: [id]) - Items Item[] @relation("ItemToItem") - ItemFolder ItemFolder? - ItemBlob ItemBlob? - ItemDocs ItemDocs? - ItemSharing ItemSharing[] + owner User @relation(fields: [ownerId], references: [id]) + parentItem Item? @relation("ItemToItem", fields: [parentId], references: [id]) + Items Item[] @relation("ItemToItem") + ItemFolder ItemFolder? + ItemBlob ItemBlob? + ItemDocs ItemDocs? + ItemSharing ItemSharing[] + ItemShortcut ItemShortcut? @relation("shortcutItem") + LinkedItemShortcut ItemShortcut[] @relation("linkedItem") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -105,3 +107,14 @@ model ItemSharing { @@index([itemId]) @@index([userId]) } + +model ItemShortcut { + id Int @id @default(autoincrement()) + itemId Int @unique + linkedItemId Int + + shortcutItem Item @relation("shortcutItem", fields: [itemId], references: [id], onDelete: Cascade) + linkedItem Item @relation("linkedItem", fields: [linkedItemId], references: [id], onDelete: Cascade) + + @@index([linkedItemId]) +} diff --git a/src/locales/da.json b/src/locales/da.json index 8ea73e2..ae33ad5 100644 --- a/src/locales/da.json +++ b/src/locales/da.json @@ -108,6 +108,9 @@ "docs": { "notFound": "Dokumentet blev ikke fundet" }, + "shortcut": { + "notFound": "Genvej blev ikke fundet" + }, "notFound": "Item ikke fundet" }, "folder": { @@ -143,5 +146,22 @@ "parentId": { "type": "Parent id skal være et tal" } + }, + "shortcut": { + "id": { + "required": "id er påkrævet", + "type": "id skal være et tal" + }, + "name": { + "required": "Navn er påkrævet", + "type": "Navn skal være en tekst" + }, + "linkedItemId": { + "required": "Det forbundet itemId er påkrævet", + "type": "Det forbundet itemId skal være en tekst" + }, + "parentId": { + "type": "Parent id skal være et tal" + } } } diff --git a/src/locales/en.json b/src/locales/en.json index b9ba1cb..72beb8d 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -108,6 +108,9 @@ "docs": { "notFound": "Docs not found" }, + "shortcut": { + "notFound": "Shortcut not found" + }, "notFound": "Item not found" }, "folder": { @@ -143,5 +146,22 @@ "parentId": { "type": "Parent id must be a number" } + }, + "shortcut": { + "id": { + "required": "id is required", + "type": "id must be a number" + }, + "name": { + "required": "Name is required", + "type": "Name must be a string" + }, + "linkedItemId": { + "required": "Linked itemId is required", + "type": "Linked itemId must be a number" + }, + "parentId": { + "type": "Parent id 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 73e184c..d6870d0 100644 --- a/src/modules/item/__test__/item.read.test.ts +++ b/src/modules/item/__test__/item.read.test.ts @@ -4,6 +4,7 @@ import FolderService from '../folder/folder.service'; import AuthService from '../../auth/auth.service'; import BlobService from '../blob/blob.service'; import DocsService from '../docs/docs.service'; +import ShortcutService from '../shortcut/shortcut.service'; describe('GET /api/item/:parentId', () => { let userService: UserService; @@ -11,6 +12,7 @@ describe('GET /api/item/:parentId', () => { let authService: AuthService; let blobService: BlobService; let docsService: DocsService; + let shortcutService: ShortcutService; let user: User; let otherUser: User; @@ -21,6 +23,7 @@ describe('GET /api/item/:parentId', () => { authService = new AuthService(); blobService = new BlobService(); docsService = new DocsService(); + shortcutService = new ShortcutService(); user = await userService.createUser({ name: 'Joe Biden the 1st', @@ -44,7 +47,7 @@ describe('GET /api/item/:parentId', () => { parentId: null, }); - await blobService.createBlob({ + const blob = await blobService.createBlob({ mimeType: 'text/plain', name: 'test1.txt', ownerId: user.id, @@ -66,6 +69,13 @@ describe('GET /api/item/:parentId', () => { parentId: parentFolder.id, }); + await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: blob.id, + parentId: parentFolder.id, + }); + const response = await global.fastify.inject({ method: 'GET', url: '/api/item/' + parentFolder.id, @@ -109,6 +119,16 @@ describe('GET /api/item/:parentId', () => { deletedAt: null, updatedAt: expect.any(String), }, + { + id: expect.any(Number), + name: 'Shortcut', + parentId: parentFolder.id, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + }, ]); }); @@ -120,7 +140,7 @@ describe('GET /api/item/:parentId', () => { parentId: null, }); - await blobService.createBlob({ + const blob = await blobService.createBlob({ mimeType: 'text/plain', name: 'test1.txt', ownerId: user.id, @@ -134,6 +154,13 @@ describe('GET /api/item/:parentId', () => { parentId: parentFolder.id, }); + await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: blob.id, + parentId: null, + }); + const response = await global.fastify.inject({ method: 'GET', url: '/api/item/' + parentFolder.id, diff --git a/src/modules/item/__test__/item.root.test.ts b/src/modules/item/__test__/item.root.test.ts index ecee662..7af6645 100644 --- a/src/modules/item/__test__/item.root.test.ts +++ b/src/modules/item/__test__/item.root.test.ts @@ -4,6 +4,7 @@ import FolderService from '../folder/folder.service'; import AuthService from '../../auth/auth.service'; import BlobService from '../blob/blob.service'; import DocsService from '../docs/docs.service'; +import ShortcutService from '../shortcut/shortcut.service'; describe('GET /api/item', () => { let userService: UserService; @@ -11,6 +12,7 @@ describe('GET /api/item', () => { let authService: AuthService; let blobService: BlobService; let docsService: DocsService; + let shortcutService: ShortcutService; let user: User; @@ -20,6 +22,7 @@ describe('GET /api/item', () => { authService = new AuthService(); blobService = new BlobService(); docsService = new DocsService(); + shortcutService = new ShortcutService(); user = await userService.createUser({ name: 'Joe Biden the 1st', @@ -39,7 +42,7 @@ describe('GET /api/item', () => { blobUrl: 'https://example.com/test1.txt', }); - await folderService.createFolder({ + const folder = await folderService.createFolder({ name: 'Folder1', color: '#123456', ownerId: user.id, @@ -59,6 +62,13 @@ describe('GET /api/item', () => { parentId: null, }); + await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + const response = await global.fastify.inject({ method: 'GET', url: '/api/item', @@ -113,6 +123,16 @@ describe('GET /api/item', () => { deletedAt: null, updatedAt: expect.any(String), }, + { + id: expect.any(Number), + name: 'Shortcut', + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + }, ]); }); @@ -125,7 +145,7 @@ describe('GET /api/item', () => { blobUrl: 'https://example.com/test1.txt', }); - await folderService.createFolder({ + const folder = await folderService.createFolder({ name: 'Folder1', color: '#123456', ownerId: user.id, @@ -138,6 +158,13 @@ describe('GET /api/item', () => { parentId: null, }); + await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + const response = await global.fastify.inject({ method: 'GET', url: '/api/item', diff --git a/src/modules/item/__test__/item.service.test.ts b/src/modules/item/__test__/item.service.test.ts index f249951..34c5a5e 100644 --- a/src/modules/item/__test__/item.service.test.ts +++ b/src/modules/item/__test__/item.service.test.ts @@ -4,6 +4,7 @@ import ItemService from '../item.service'; import FolderService from '../folder/folder.service'; import SharingService from '../sharing/sharing.service'; import BlobService from '../blob/blob.service'; +import ShortcutService from '../shortcut/shortcut.service'; describe('ItemService', () => { let itemService: ItemService; @@ -11,6 +12,7 @@ describe('ItemService', () => { let folderService: FolderService; let sharingService: SharingService; let blobService: BlobService; + let shortcutService: ShortcutService; let user: User; let otherUser: User; @@ -21,6 +23,7 @@ describe('ItemService', () => { folderService = new FolderService(); sharingService = new SharingService(); blobService = new BlobService(); + shortcutService = new ShortcutService(); user = await userService.createUser({ name: 'Joe Biden the 1st', @@ -44,7 +47,7 @@ describe('ItemService', () => { blobUrl: 'https://example.com/test1.txt', }); - await folderService.createFolder({ + const folder = await folderService.createFolder({ name: 'Folder1', color: '#123456', ownerId: user.id, @@ -58,6 +61,13 @@ describe('ItemService', () => { parentId: null, }); + await shortcutService.createShortcut({ + name: 'Shortcut', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + const items = await itemService.getByOwnerIdAndParentId(user.id, null); expect(items).toEqual([ @@ -97,6 +107,18 @@ describe('ItemService', () => { deletedAt: null, updatedAt: expect.any(Date), }, + { + id: expect.any(Number), + name: 'Shortcut', + parentId: null, + ownerId: user.id, + linkedItemId: folder.id, + itemId: expect.any(Number), + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(Date), + deletedAt: null, + updatedAt: expect.any(Date), + }, ]); }); @@ -170,6 +192,25 @@ describe('ItemService', () => { userId: otherUser.id, }); + const shortcut1 = await shortcutService.createShortcut({ + name: 'Shortcut1', + ownerId: user.id, + linkedItemId: folder1.id, + parentId: folder.id, + }); + + await shortcutService.createShortcut({ + name: 'Shortcut2', + ownerId: user.id, + linkedItemId: folder3.id, + parentId: folder.id, + }); + + await sharingService.createSharing({ + itemId: shortcut1.id, + userId: otherUser.id, + }); + const itemsOwner = await itemService.getAllOwnedAndSharredItemsByParentIdAndUserId( user.id, folder.id, @@ -240,6 +281,30 @@ describe('ItemService', () => { deletedAt: null, updatedAt: expect.any(Date), }, + { + id: expect.any(Number), + name: 'Shortcut1', + parentId: folder.id, + linkedItemId: folder1.id, + ownerId: user.id, + itemId: expect.any(Number), + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(Date), + deletedAt: null, + updatedAt: expect.any(Date), + }, + { + id: expect.any(Number), + name: 'Shortcut2', + parentId: folder.id, + linkedItemId: folder3.id, + ownerId: user.id, + itemId: expect.any(Number), + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(Date), + deletedAt: null, + updatedAt: expect.any(Date), + }, ]; const expectedSharredUser = [ { @@ -278,6 +343,18 @@ describe('ItemService', () => { deletedAt: null, updatedAt: expect.any(Date), }, + { + id: expect.any(Number), + name: 'Shortcut1', + parentId: folder.id, + linkedItemId: folder1.id, + ownerId: user.id, + itemId: expect.any(Number), + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(Date), + deletedAt: null, + updatedAt: expect.any(Date), + }, ]; expect(itemsOwner).toEqual(expectedOwner); diff --git a/src/modules/item/folder/__test__/edit.test.ts b/src/modules/item/folder/__test__/edit.test.ts index 85a908e..5a381be 100644 --- a/src/modules/item/folder/__test__/edit.test.ts +++ b/src/modules/item/folder/__test__/edit.test.ts @@ -93,6 +93,47 @@ describe('PUT /api/folder', () => { }); }); + it('should return status 401, when moving folder to a folder without sharing access', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const parentFolder = await folderService.createFolder({ + name: 'Parent', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/folder', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: folder.id, + name: 'Folder1', + color: '#78BC61', + parentId: parentFolder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + it('should return status 401, when folder id is not accessible to you', async () => { const { accessToken } = await authService.createTokens(user.id); diff --git a/src/modules/item/folder/folder.controller.ts b/src/modules/item/folder/folder.controller.ts index 027de54..1a51753 100644 --- a/src/modules/item/folder/folder.controller.ts +++ b/src/modules/item/folder/folder.controller.ts @@ -49,6 +49,14 @@ export default class FolderController { return reply.unauthorized(); } + if ( + request.body.parentId !== null && + request.body.parentId !== undefined && + !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + ) { + return reply.unauthorized(); + } + const updatedFolder = await this.folderService.updateFolder(request.body); return reply.code(200).send(updatedFolder); diff --git a/src/modules/item/index.ts b/src/modules/item/index.ts index cfbb51c..5cc64d1 100644 --- a/src/modules/item/index.ts +++ b/src/modules/item/index.ts @@ -6,6 +6,7 @@ import docs from './docs'; import folder from './folder'; import sharing from './sharing'; import itemRoute from './item.route'; +import shortcut from './shortcut'; import { itemSchemas } from './item.schema'; export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPluginOptions) => { @@ -13,6 +14,7 @@ export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPl await fastify.register(docs, getOptionsWithPrefix(options, '/docs')); await fastify.register(folder, getOptionsWithPrefix(options, '/folder')); await fastify.register(sharing, getOptionsWithPrefix(options, '/sharing')); + await fastify.register(shortcut, getOptionsWithPrefix(options, '/shortcut')); await fastify.register(itemRoute, getOptionsWithPrefix(options, '/item')); for (const schema of itemSchemas) { diff --git a/src/modules/item/item.schema.ts b/src/modules/item/item.schema.ts index 2cf067e..1d4eb2a 100644 --- a/src/modules/item/item.schema.ts +++ b/src/modules/item/item.schema.ts @@ -3,6 +3,7 @@ import { ItemBlob as prismaItemBlobType, ItemFolder as prismaItemFolderType, ItemDocs as prismaItemDocsType, + ItemShortcut as prismaItemShortcutType, } from '@prisma/client'; import { FromSchema } from 'json-schema-to-ts'; @@ -10,12 +11,13 @@ export type Item = prismaItemType; export type ItemPrismaProperties = Item & { ItemBlob: prismaItemBlobType | null } & { ItemFolder: prismaItemFolderType | null; -} & { ItemDocs: prismaItemDocsType | null }; +} & { ItemDocs: prismaItemDocsType | null } & { ItemShortcut: prismaItemShortcutType | null }; export type ItemWithProperties = Item & Omit, 'id' | 'itemId'> & Omit, 'id' | 'itemId'> & - Omit, 'id' | 'itemId'>; + Omit, 'id' | 'itemId'> & + Omit, 'id' | 'itemId'>; export type CreateItem = Omit; diff --git a/src/modules/item/item.service.ts b/src/modules/item/item.service.ts index 4684825..b4f447f 100644 --- a/src/modules/item/item.service.ts +++ b/src/modules/item/item.service.ts @@ -42,6 +42,7 @@ export default class ItemService { ItemBlob: true, ItemFolder: true, ItemDocs: true, + ItemShortcut: true, }, }); @@ -72,6 +73,7 @@ export default class ItemService { ItemBlob: true, ItemFolder: true, ItemDocs: true, + ItemShortcut: true, }, }); @@ -80,12 +82,13 @@ export default class ItemService { private formatItems(items: ItemPrismaProperties[]): ItemWithProperties[] { return items.map((element) => { - const { ItemFolder, ItemBlob, ItemDocs, ...strippedElement } = element; + const { ItemFolder, ItemBlob, ItemDocs, ItemShortcut, ...strippedElement } = element; return { ...ItemBlob, ...ItemFolder, ...ItemDocs, + ...ItemShortcut, ...strippedElement, }; }); diff --git a/src/modules/item/shortcut/__test__/add.test.ts b/src/modules/item/shortcut/__test__/add.test.ts new file mode 100644 index 0000000..b2f5511 --- /dev/null +++ b/src/modules/item/shortcut/__test__/add.test.ts @@ -0,0 +1,283 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import FolderService from '../../folder/folder.service'; + +describe('POST /api/shortcut', () => { + let userService: UserService; + let authService: AuthService; + let folderService: FolderService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + folderService = new FolderService(); + + 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/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Shortcut Folder', + linkedItemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: expect.any(Number), + name: 'Shortcut Folder', + linkedItemId: folder.id, + parentId: null, + ownerId: user.id, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + }); + }); + + it('should return status 200 and item, when adding a shortcut twice', 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/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Shortcut Folder', + linkedItemId: folder.id, + }, + }); + + const response2 = await global.fastify.inject({ + method: 'POST', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Shortcut Folder2', + linkedItemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + id: expect.any(Number), + name: 'Shortcut Folder', + parentId: null, + ownerId: user.id, + linkedItemId: folder.id, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + }); + expect(response2.json()).toEqual({ + id: expect.any(Number), + name: 'Shortcut Folder2', + parentId: null, + ownerId: user.id, + linkedItemId: folder.id, + mimeType: 'application/vnd.cloudstore.shortcut', + createdAt: expect.any(String), + deletedAt: null, + updatedAt: expect.any(String), + }); + }); + + 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/shortcut', + headers: { + authorization: 'invalid_access_token!!!', + }, + payload: { + name: 'Shortcut Folder', + linkedItemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when parent id is provided, but no access to parent', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const parentFolder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Shortcut Folder', + linkedItemId: folder.id, + parentId: parentFolder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when shortcut name is not provided', 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/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + linkedItemId: folder.id, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + _: ['Name is required'], + }, + statusCode: 400, + }); + }); + + it('should return status 401, when shortcut linkedItemId is not provided', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'POST', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Shortcut Folder', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + _: ['Linked itemId is required'], + }, + statusCode: 400, + }); + }); + + it("should return status 400, when parent id isn't a number", 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/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'Folder Name', + linkedItemId: folder.id, + parentId: 'invalid_id', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + parentId: ['Parent id must be a number'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/shortcut/__test__/delete.test.ts b/src/modules/item/shortcut/__test__/delete.test.ts new file mode 100644 index 0000000..5315a2e --- /dev/null +++ b/src/modules/item/shortcut/__test__/delete.test.ts @@ -0,0 +1,178 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import ShortcutService from '../shortcut.service'; +import FolderService from '../../folder/folder.service'; +import ItemService from '../../item.service'; + +describe('DELETE /api/shortcut/:id', () => { + let userService: UserService; + let authService: AuthService; + let shortcutService: ShortcutService; + let folderService: FolderService; + let itemService: ItemService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + shortcutService = new ShortcutService(); + folderService = new FolderService(); + itemService = 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 shortcut 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 shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(204); + expect(response.body).toBe(''); + + await expect(shortcutService.getByItemId(shortcut.id)).rejects.toThrowError(); + await expect(itemService.getById(shortcut.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 shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'invalid_access_token!!!', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when shortcut id is not accessible to you', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: otherUser.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it("should return status 400, when shortcut id isn't a number", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/shortcut/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 shortcut with id doesn't exist", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'DELETE', + url: '/api/shortcut/1234', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Shortcut not found'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/shortcut/__test__/edit.test.ts b/src/modules/item/shortcut/__test__/edit.test.ts new file mode 100644 index 0000000..3522072 --- /dev/null +++ b/src/modules/item/shortcut/__test__/edit.test.ts @@ -0,0 +1,270 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import ShortcutService from '../shortcut.service'; +import FolderService from '../../folder/folder.service'; + +describe('PUT /api/shortcut', () => { + let userService: UserService; + let authService: AuthService; + let shortcutService: ShortcutService; + let folderService: FolderService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + shortcutService = new ShortcutService(); + folderService = new FolderService(); + + 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 shortcut', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: shortcut.id, + name: shortcut.name + ' Updated', + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + ...shortcut, + name: shortcut.name + ' Updated', + linkedItemId: folder.id, + createdAt: shortcut.createdAt.toISOString(), + updatedAt: expect.any(String), + deletedAt: shortcut.deletedAt?.toISOString() ?? null, + }); + }); + + it('should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'invalid_access_token!!!', + }, + payload: { + id: shortcut.id, + name: shortcut.name + ' Updated', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when moving shortcut to a folder without sharing access', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const parentFolder = await folderService.createFolder({ + name: 'Parent', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: shortcut.id, + name: 'Shortcut Folder', + parentId: parentFolder.id, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when shortcut id is not accessible to you', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: otherUser.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: shortcut.id, + name: 'Folder Shortcut Updated', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it("should return status 400, when shortcut id isn't a number", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: 'invalid_id', + name: 'updated', + }, + }); + + 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 shortcut id isn't given", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + name: 'updated', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'ValidationError', + errors: { + _: ['id is required'], + }, + statusCode: 400, + }); + }); + + it("should return status 400, when shortcut with id doesn't exist", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'PUT', + url: '/api/shortcut', + headers: { + authorization: 'Bearer ' + accessToken, + }, + payload: { + id: 1234, + name: 'updated', + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Shortcut not found'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/shortcut/__test__/read.test.ts b/src/modules/item/shortcut/__test__/read.test.ts new file mode 100644 index 0000000..c36e8a9 --- /dev/null +++ b/src/modules/item/shortcut/__test__/read.test.ts @@ -0,0 +1,178 @@ +import { User } from '@prisma/client'; +import UserService from '../../../auth/user.service'; +import AuthService from '../../../auth/auth.service'; +import ShortcutService from '../shortcut.service'; +import FolderService from '../../folder/folder.service'; + +describe('GET /api/shortcut/:id', () => { + let userService: UserService; + let authService: AuthService; + let shortcutService: ShortcutService; + let folderService: FolderService; + + let user: User; + let otherUser: User; + + beforeAll(async () => { + authService = new AuthService(); + userService = new UserService(); + shortcutService = new ShortcutService(); + folderService = new FolderService(); + + 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 shortcut', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toEqual({ + ...shortcut, + linkedItemId: folder.id, + createdAt: shortcut.createdAt.toISOString(), + updatedAt: shortcut.updatedAt.toISOString(), + deletedAt: shortcut.deletedAt?.toISOString() ?? null, + }); + }); + + it('should return status 401, when unauthorized', async () => { + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: user.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: user.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'invalid_access_token!!!', + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it('should return status 401, when shortcut id is not accessible to you', async () => { + const { accessToken } = await authService.createTokens(user.id); + + const folder = await folderService.createFolder({ + name: 'Folder1', + ownerId: otherUser.id, + parentId: null, + color: '#78BC61', + }); + + const shortcut = await shortcutService.createShortcut({ + name: 'Shortcut Folder', + ownerId: otherUser.id, + linkedItemId: folder.id, + parentId: null, + }); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/shortcut/' + shortcut.id, + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(401); + expect(response.json()).toEqual({ + error: 'UnauthorizedError', + errors: { + _: ['Unauthorized'], + }, + statusCode: 401, + }); + }); + + it("should return status 400, when shortcut id isn't a number", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/shortcut/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 shortcut with id doesn't exist", async () => { + const { accessToken } = await authService.createTokens(user.id); + + const response = await global.fastify.inject({ + method: 'GET', + url: '/api/shortcut/1234', + headers: { + authorization: 'Bearer ' + accessToken, + }, + }); + + expect(response.statusCode).toBe(400); + expect(response.json()).toEqual({ + error: 'BadRequestError', + errors: { + _: ['Shortcut not found'], + }, + statusCode: 400, + }); + }); +}); diff --git a/src/modules/item/shortcut/index.ts b/src/modules/item/shortcut/index.ts new file mode 100644 index 0000000..84e27a1 --- /dev/null +++ b/src/modules/item/shortcut/index.ts @@ -0,0 +1,13 @@ +import { FastifyInstance, FastifyPluginOptions } from 'fastify'; + +import fastifyPlugin from 'fastify-plugin'; +import { shortcutSchemas } from './shortcut.schema'; +import shortcutRoute from './shortcut.route'; + +export default fastifyPlugin(async (fastify: FastifyInstance, options: FastifyPluginOptions) => { + for (const schema of shortcutSchemas) { + fastify.addSchema(schema); + } + + await fastify.register(shortcutRoute, options); +}); diff --git a/src/modules/item/shortcut/shortcut.controller.ts b/src/modules/item/shortcut/shortcut.controller.ts new file mode 100644 index 0000000..dd9dda4 --- /dev/null +++ b/src/modules/item/shortcut/shortcut.controller.ts @@ -0,0 +1,126 @@ +import { FastifyReply, FastifyRequest } from 'fastify'; +import { ReadInput, EditInput, AddInput, DeleteInput } from './shortcut.schema'; +import ShortcutService from './shortcut.service'; +import AccessService from '../sharing/access.service'; + +export default class ShortcutController { + private shortcutService: ShortcutService; + private accessService: AccessService; + + constructor(shortcutService: ShortcutService, accessService: AccessService) { + this.shortcutService = shortcutService; + this.accessService = accessService; + } + + public async readHandler( + request: FastifyRequest<{ + Params: ReadInput; + }>, + reply: FastifyReply, + ) { + try { + const shortcut = await this.shortcutService.getByItemId(request.params.id); + + if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + return reply.unauthorized(); + } + + return reply.code(200).send(shortcut); + } catch (e) { + if (e instanceof Error) { + return reply.badRequest(request.i18n.t(e.message)); + } + + /* istanbul ignore next */ + return reply.badRequest(); + } + } + + public async editHandler( + request: FastifyRequest<{ + Body: EditInput; + }>, + reply: FastifyReply, + ) { + try { + const shortcut = await this.shortcutService.getByItemId(request.body.id); + + if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + return reply.unauthorized(); + } + + if ( + request.body.parentId !== null && + request.body.parentId !== undefined && + !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + ) { + return reply.unauthorized(); + } + + const updatedShortcut = await this.shortcutService.updateShortcut(request.body); + + return reply.code(200).send(updatedShortcut); + } catch (e) { + if (e instanceof Error) { + return reply.badRequest(request.i18n.t(e.message)); + } + + /* istanbul ignore next */ + return reply.badRequest(); + } + } + + public async addHandler( + request: FastifyRequest<{ + Body: AddInput; + }>, + reply: FastifyReply, + ) { + try { + if ( + request.body.parentId !== null && + request.body.parentId !== undefined && + !(await this.accessService.hasAccessToItem(request.body.parentId, request.user.sub)) + ) { + return reply.unauthorized(); + } + + const shortcut = await this.shortcutService.createShortcut({ + name: request.body.name, + linkedItemId: request.body.linkedItemId, + ownerId: request.user.sub, + parentId: request.body.parentId ?? null, + }); + + return reply.code(200).send(shortcut); + } catch (e) { + /* istanbul ignore next */ + return reply.badRequest(); + } + } + + public async deleteHandler( + request: FastifyRequest<{ + Params: DeleteInput; + }>, + reply: FastifyReply, + ) { + try { + const shortcut = await this.shortcutService.getByItemId(request.params.id); + + if (!(await this.accessService.hasAccessToItem(shortcut.id, request.user.sub))) { + return reply.unauthorized(); + } + + await this.shortcutService.deleteShortcutByItemId(shortcut.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/shortcut/shortcut.route.ts b/src/modules/item/shortcut/shortcut.route.ts new file mode 100644 index 0000000..511e4d4 --- /dev/null +++ b/src/modules/item/shortcut/shortcut.route.ts @@ -0,0 +1,91 @@ +import { FastifyInstance } from 'fastify'; +import ShortcutController from './shortcut.controller'; +import ShortcutService from './shortcut.service'; +import AccessService from '../sharing/access.service'; +import ItemService from '../item.service'; +import SharingService from '../sharing/sharing.service'; + +export default async (fastify: FastifyInstance) => { + const shortcutService = new ShortcutService(); + const shortcutController = new ShortcutController( + shortcutService, + new AccessService(new ItemService(), new SharingService()), + ); + + fastify.get( + '/:id', + { + schema: { + tags: ['Shortcut'], + params: { $ref: 'readShortcutSchema' }, + response: { + 200: { $ref: 'readShortcutResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + shortcutController.readHandler.bind(shortcutController), + ); + + fastify.put( + '/', + { + schema: { + tags: ['Shortcut'], + body: { $ref: 'editShortcutSchema' }, + response: { + 200: { $ref: 'editShortcutResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + shortcutController.editHandler.bind(shortcutController), + ); + + fastify.post( + '/', + { + schema: { + tags: ['Shortcut'], + body: { $ref: 'addShortcutSchema' }, + response: { + 200: { $ref: 'addShortcutResponseSchema' }, + }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + shortcutController.addHandler.bind(shortcutController), + ); + + fastify.delete( + '/:id', + { + schema: { + tags: ['Shortcut'], + params: { $ref: 'deleteShortcutSchema' }, + security: [ + { + bearerAuth: [], + }, + ], + }, + onRequest: [fastify.authenticate], + }, + shortcutController.deleteHandler.bind(shortcutController), + ); +}; diff --git a/src/modules/item/shortcut/shortcut.schema.ts b/src/modules/item/shortcut/shortcut.schema.ts new file mode 100644 index 0000000..13d976f --- /dev/null +++ b/src/modules/item/shortcut/shortcut.schema.ts @@ -0,0 +1,232 @@ +import { FromSchema } from 'json-schema-to-ts'; +import { Item, UpdateItem } from '../item.schema'; +import { ItemShortcut as prismaItemShortcutType } from '@prisma/client'; + +const readShortcutSchema = { + $id: 'readShortcutSchema', + type: 'object', + properties: { + id: { + type: 'number', + errorMessage: { + type: 'shortcut.id.type', + }, + }, + }, + required: ['id'], + errorMessage: { + required: { + id: 'shortcut.id.required', + }, + }, +} as const; + +const readShortcutResponseSchema = { + $id: 'readShortcutResponseSchema', + type: 'object', + properties: { + id: { + type: 'number', + }, + parentId: { + type: ['number', 'null'], + }, + linkedItemId: { + type: 'number', + }, + name: { + type: 'string', + }, + mimeType: { + type: 'string', + }, + ownerId: { + type: 'number', + }, + deletedAt: { + type: ['string', 'null'], + }, + createdAt: { + type: 'string', + }, + updatedAt: { + type: 'string', + }, + }, +} as const; + +const editShortcutSchema = { + $id: 'editShortcutSchema', + type: 'object', + properties: { + id: { + type: 'number', + errorMessage: { + type: 'shortcut.id.type', + }, + }, + name: { + type: 'string', + errorMessage: { + type: 'shortcut.name.type', + }, + }, + parentId: { + type: ['number', 'null'], + errorMessage: { + type: 'shortcut.parentId.type', + }, + }, + }, + required: ['id', 'name'], + errorMessage: { + required: { + id: 'shortcut.id.required', + name: 'shortcut.name.required', + }, + }, +} as const; + +const editShortcutResponseSchema = { + $id: 'editShortcutResponseSchema', + type: 'object', + properties: { + id: { + type: 'number', + }, + parentId: { + type: ['number', 'null'], + }, + linkedItemId: { + type: 'number', + }, + name: { + type: 'string', + }, + mimeType: { + type: 'string', + }, + ownerId: { + type: 'number', + }, + deletedAt: { + type: ['string', 'null'], + }, + createdAt: { + type: 'string', + }, + updatedAt: { + type: 'string', + }, + }, +} as const; +const addShortcutSchema = { + $id: 'addShortcutSchema', + type: 'object', + properties: { + name: { + type: 'string', + errorMessage: { + type: 'shortcut.name.type', + }, + }, + linkedItemId: { + type: 'number', + errorMessage: { + type: 'shortcut.linkedItemId.type', + }, + }, + parentId: { + type: ['number', 'null'], + errorMessage: { + type: 'shortcut.parentId.type', + }, + }, + }, + required: ['name', 'linkedItemId'], + errorMessage: { + required: { + name: 'shortcut.name.required', + linkedItemId: 'shortcut.linkedItemId.required', + }, + }, +} as const; + +const addShortcutResponseSchema = { + $id: 'addShortcutResponseSchema', + type: 'object', + properties: { + id: { + type: 'number', + }, + name: { + type: 'string', + }, + linkedItemId: { + type: 'number', + }, + parentId: { + type: ['number', 'null'], + }, + mimeType: { + type: 'string', + }, + ownerId: { + type: 'number', + }, + deletedAt: { + type: ['string', 'null'], + }, + createdAt: { + type: 'string', + }, + updatedAt: { + type: 'string', + }, + }, +} as const; + +const deleteShortcutSchema = { + $id: 'deleteShortcutSchema', + type: 'object', + properties: { + id: { + type: 'number', + errorMessage: { + type: 'shortcut.id.type', + }, + }, + }, + required: ['id'], + errorMessage: { + required: { + itemId: 'shortcut.id.required', + }, + }, +} as const; + +export type AddInput = FromSchema; +export type ReadInput = FromSchema; +export type EditInput = FromSchema; +export type DeleteInput = FromSchema; + +export type AddShortcut = { + name: string; + linkedItemId: number; + ownerId: number; + parentId: number | null; +}; + +export type ItemShortcut = prismaItemShortcutType & { shortcutItem: Item }; +export type Shortcut = Omit & Item; +export type UpdateShortcut = { id: number } & Partial & Omit; + +export const shortcutSchemas = [ + addShortcutSchema, + addShortcutResponseSchema, + readShortcutSchema, + readShortcutResponseSchema, + editShortcutSchema, + editShortcutResponseSchema, + deleteShortcutSchema, +]; diff --git a/src/modules/item/shortcut/shortcut.service.ts b/src/modules/item/shortcut/shortcut.service.ts new file mode 100644 index 0000000..9689cff --- /dev/null +++ b/src/modules/item/shortcut/shortcut.service.ts @@ -0,0 +1,82 @@ +import { prisma } from '../../../plugins/prisma'; +import { Shortcut, AddShortcut, UpdateShortcut, ItemShortcut } from './shortcut.schema'; + +export default class ShortcutService { + public async createShortcut(input: AddShortcut): Promise { + const itemShortcut = await prisma.itemShortcut.create({ + data: { + linkedItem: { + connect: { + id: input.linkedItemId, + }, + }, + shortcutItem: { + create: { + name: input.name, + mimeType: 'application/vnd.cloudstore.shortcut', + ownerId: input.ownerId, + parentId: input.parentId, + }, + }, + }, + include: { + shortcutItem: true, + }, + }); + + return this.formatItemShortcut(itemShortcut); + } + + public async getByItemId(itemId: number): Promise { + const itemShortcut = await prisma.itemShortcut.findUnique({ + where: { + itemId, + }, + include: { + shortcutItem: true, + }, + }); + + if (!itemShortcut) { + throw new Error('item.shortcut.notFound'); + } + + return this.formatItemShortcut(itemShortcut); + } + + public async updateShortcut(input: UpdateShortcut): Promise { + const itemShortcut = await prisma.itemShortcut.update({ + data: { + shortcutItem: { + update: { + name: input.name, + parentId: input.parentId, + }, + }, + }, + where: { + itemId: input.id, + }, + include: { + shortcutItem: true, + }, + }); + + return this.formatItemShortcut(itemShortcut); + } + + public async deleteShortcutByItemId(itemId: number): Promise { + await prisma.item.delete({ + where: { + id: itemId, + }, + }); + } + + private formatItemShortcut(itemShortcut: ItemShortcut): Shortcut { + return { + linkedItemId: itemShortcut.linkedItemId, + ...itemShortcut.shortcutItem, + }; + } +}