From 636223bc9052e6573bd0f4ff9cf3920050dc8bbe Mon Sep 17 00:00:00 2001 From: Federico Ruggi <1081051+ruggi@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:52:16 +0100 Subject: [PATCH] soft-delete and restore projects --- .../app/models/project.server.spec.ts | 124 +++++++++++++++++- utopia-remix/app/models/project.server.ts | 33 ++++- .../routes-test/projects.$id.delete.spec.ts | 68 ++++++++++ .../routes-test/projects.$id.rename.spec.ts | 5 - .../routes-test/projects.$id.restore.spec.ts | 81 ++++++++++++ .../app/routes/projects.$id.delete.tsx | 20 +++ .../app/routes/projects.$id.rename.tsx | 2 - .../app/routes/projects.$id.restore.tsx | 20 +++ utopia-remix/app/routes/projects.tsx | 5 +- utopia-remix/app/test-util.ts | 2 + 10 files changed, 345 insertions(+), 15 deletions(-) create mode 100644 utopia-remix/app/routes-test/projects.$id.delete.spec.ts create mode 100644 utopia-remix/app/routes-test/projects.$id.restore.spec.ts create mode 100644 utopia-remix/app/routes/projects.$id.delete.tsx create mode 100644 utopia-remix/app/routes/projects.$id.restore.tsx diff --git a/utopia-remix/app/models/project.server.spec.ts b/utopia-remix/app/models/project.server.spec.ts index d64bfdfdd0da..22927d297e5b 100644 --- a/utopia-remix/app/models/project.server.spec.ts +++ b/utopia-remix/app/models/project.server.spec.ts @@ -1,12 +1,17 @@ import moment from 'moment' import { prisma } from '../db.server' -import { createTestProject, truncateTables } from '../test-util' -import { listProjects } from './project.server' +import { createTestProject, createTestUser, truncateTables } from '../test-util' +import { + listProjects, + renameProject, + restoreDeletedProject, + softDeleteProject, +} from './project.server' describe('project model', () => { afterEach(async () => { // cleanup - await truncateTables([prisma.projectID, prisma.project]) + await truncateTables([prisma.projectID, prisma.project, prisma.userDetails]) }) describe('listProjects', () => { @@ -60,6 +65,119 @@ describe('project model', () => { const aliceProjects = await listProjects({ ownerId: 'alice' }) expect(aliceProjects.map((p) => p.proj_id)).toEqual(['baz']) }) + + it('ignores soft-deleted projects', async () => { + await createTestProject(prisma, { id: 'foo', ownerId: 'bob' }) + await createTestProject(prisma, { id: 'bar', ownerId: 'bob', deleted: true }) + await createTestProject(prisma, { id: 'baz', ownerId: 'bob' }) + + const bobProjects = await listProjects({ ownerId: 'bob' }) + expect(bobProjects.length).toBe(2) + expect(bobProjects.map((p) => p.proj_id)).toEqual(['baz', 'foo']) + }) + }) + }) + + describe('renameProject', () => { + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestProject(prisma, { id: 'foo', ownerId: 'bob', title: 'the-project' }) + await createTestProject(prisma, { + id: 'deleted-project', + ownerId: 'bob', + title: 'the-project', + deleted: true, + }) + }) + it('requires the user', async () => { + const fn = async () => renameProject({ id: 'foo', userId: 'JOHN-DOE', title: 'test' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project', async () => { + const fn = async () => renameProject({ id: 'bar', userId: 'bob', title: 'test' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project ownership', async () => { + const fn = async () => renameProject({ id: 'foo', userId: 'alice', title: 'test' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project not to be soft-deleted', async () => { + const fn = async () => renameProject({ id: 'deleted-project', userId: 'bob', title: 'test' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('renames the project', async () => { + await renameProject({ id: 'foo', userId: 'bob', title: 'renamed' }) + const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } }) + expect(got?.title).toEqual('renamed') + }) + }) + + describe('softDeleteProject', () => { + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestProject(prisma, { id: 'foo', ownerId: 'bob' }) + await createTestProject(prisma, { + id: 'deleted-project', + ownerId: 'bob', + deleted: true, + }) + }) + it('requires the user', async () => { + const fn = async () => softDeleteProject({ id: 'foo', userId: 'JOHN-DOE' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project', async () => { + const fn = async () => softDeleteProject({ id: 'bar', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project ownership', async () => { + const fn = async () => softDeleteProject({ id: 'foo', userId: 'alice' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project not to be soft-deleted', async () => { + const fn = async () => softDeleteProject({ id: 'deleted-project', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('soft-deletes the project', async () => { + await softDeleteProject({ id: 'foo', userId: 'bob' }) + const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } }) + expect(got?.deleted).toEqual(true) + }) + }) + + describe('restoreDeletedProject', () => { + beforeEach(async () => { + await createTestUser(prisma, { id: 'bob' }) + await createTestUser(prisma, { id: 'alice' }) + await createTestProject(prisma, { id: 'foo', ownerId: 'bob' }) + await createTestProject(prisma, { + id: 'deleted-project', + ownerId: 'bob', + deleted: true, + }) + }) + it('requires the user', async () => { + const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'JOHN-DOE' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project', async () => { + const fn = async () => restoreDeletedProject({ id: 'bar', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project ownership', async () => { + const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'alice' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires the project to be soft-deleted', async () => { + const fn = async () => restoreDeletedProject({ id: 'foo', userId: 'bob' }) + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('restores the project', async () => { + await restoreDeletedProject({ id: 'deleted-project', userId: 'bob' }) + const got = await prisma.project.findFirst({ where: { proj_id: 'foo' } }) + expect(got?.deleted).toEqual(null) }) }) }) diff --git a/utopia-remix/app/models/project.server.ts b/utopia-remix/app/models/project.server.ts index b0f282299371..d6d9519f2dd6 100644 --- a/utopia-remix/app/models/project.server.ts +++ b/utopia-remix/app/models/project.server.ts @@ -16,7 +16,10 @@ const selectProjectWithoutContent: Record = { export async function listProjects(params: { ownerId: string }): Promise { return prisma.project.findMany({ select: selectProjectWithoutContent, - where: { owner_id: params.ownerId }, + where: { + owner_id: params.ownerId, + OR: [{ deleted: null }, { deleted: false }], + }, orderBy: { modified_at: 'desc' }, }) } @@ -27,8 +30,34 @@ export async function renameProject(params: { title: string }): Promise { return prisma.project.update({ - where: { proj_id: params.id, owner_id: params.userId }, + where: { + proj_id: params.id, + owner_id: params.userId, + OR: [{ deleted: null }, { deleted: false }], + }, data: { title: params.title }, select: selectProjectWithoutContent, }) } + +export async function softDeleteProject(params: { id: string; userId: string }): Promise { + await prisma.project.update({ + where: { + proj_id: params.id, + owner_id: params.userId, + OR: [{ deleted: null }, { deleted: false }], + }, + data: { deleted: true }, + }) +} + +export async function restoreDeletedProject(params: { id: string; userId: string }): Promise { + await prisma.project.update({ + where: { + proj_id: params.id, + owner_id: params.userId, + deleted: true, + }, + data: { deleted: null }, + }) +} diff --git a/utopia-remix/app/routes-test/projects.$id.delete.spec.ts b/utopia-remix/app/routes-test/projects.$id.delete.spec.ts new file mode 100644 index 000000000000..907f910a65ec --- /dev/null +++ b/utopia-remix/app/routes-test/projects.$id.delete.spec.ts @@ -0,0 +1,68 @@ +import { prisma } from '../db.server' +import { handleDeleteProject } from '../routes/projects.$id.delete' +import { + createTestProject, + createTestSession, + createTestUser, + newTestRequest, + truncateTables, +} from '../test-util' +import { ApiError } from '../util/api.server' + +describe('handleDeleteProject', () => { + afterEach(async () => { + await truncateTables([ + prisma.userDetails, + prisma.persistentSession, + prisma.project, + prisma.projectID, + ]) + }) + + beforeEach(async () => { + await createTestUser(prisma, { id: 'foo' }) + await createTestUser(prisma, { id: 'bar' }) + await createTestSession(prisma, { key: 'the-key', userId: 'foo' }) + await createTestProject(prisma, { id: 'one', ownerId: 'foo', title: 'project-one' }) + await createTestProject(prisma, { id: 'two', ownerId: 'bar', title: 'project-two' }) + }) + + it('requires a user', async () => { + const fn = async () => + handleDeleteProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {}) + await expect(fn).rejects.toThrow(ApiError) + await expect(fn).rejects.toThrow('session not found') + }) + it('requires a valid id', async () => { + const fn = async () => + handleDeleteProject(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {}) + await expect(fn).rejects.toThrow(ApiError) + await expect(fn).rejects.toThrow('id is null') + }) + it('requires a valid project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDeleteProject(req, { id: 'doesnt-exist' }) + } + + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires ownership of the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDeleteProject(req, { id: 'two' }) + } + + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('soft-deletes the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleDeleteProject(req, { id: 'one' }) + } + + await fn() + const project = await prisma.project.findFirst({ where: { proj_id: 'one' } }) + expect(project?.deleted).toEqual(true) + }) +}) diff --git a/utopia-remix/app/routes-test/projects.$id.rename.spec.ts b/utopia-remix/app/routes-test/projects.$id.rename.spec.ts index 287bfb059df7..6d074406690e 100644 --- a/utopia-remix/app/routes-test/projects.$id.rename.spec.ts +++ b/utopia-remix/app/routes-test/projects.$id.rename.spec.ts @@ -28,11 +28,6 @@ describe('handleRenameProject', () => { await createTestProject(prisma, { id: 'two', ownerId: 'bar', title: 'project-two' }) }) - it('requires a POST method', async () => { - const fn = async () => handleRenameProject(newTestRequest(), {}) - await expect(fn).rejects.toThrow(ApiError) - await expect(fn).rejects.toThrow('invalid method') - }) it('requires a user', async () => { const fn = async () => handleRenameProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {}) diff --git a/utopia-remix/app/routes-test/projects.$id.restore.spec.ts b/utopia-remix/app/routes-test/projects.$id.restore.spec.ts new file mode 100644 index 000000000000..84d4a77d5858 --- /dev/null +++ b/utopia-remix/app/routes-test/projects.$id.restore.spec.ts @@ -0,0 +1,81 @@ +import { prisma } from '../db.server' +import { handleRestoreDeletedProject } from '../routes/projects.$id.restore' +import { + createTestProject, + createTestSession, + createTestUser, + newTestRequest, + truncateTables, +} from '../test-util' +import { ApiError } from '../util/api.server' + +describe('handleRestoreDeletedProject', () => { + afterEach(async () => { + await truncateTables([ + prisma.userDetails, + prisma.persistentSession, + prisma.project, + prisma.projectID, + ]) + }) + + beforeEach(async () => { + await createTestUser(prisma, { id: 'foo' }) + await createTestUser(prisma, { id: 'bar' }) + await createTestSession(prisma, { key: 'the-key', userId: 'foo' }) + await createTestProject(prisma, { + id: 'one', + ownerId: 'foo', + title: 'project-one', + deleted: true, + }) + await createTestProject(prisma, { id: 'two', ownerId: 'foo', title: 'project-two' }) + }) + + it('requires a user', async () => { + const fn = async () => + handleRestoreDeletedProject(newTestRequest({ method: 'POST', authCookie: 'wrong-key' }), {}) + await expect(fn).rejects.toThrow(ApiError) + await expect(fn).rejects.toThrow('session not found') + }) + it('requires a valid id', async () => { + const fn = async () => + handleRestoreDeletedProject(newTestRequest({ method: 'POST', authCookie: 'the-key' }), {}) + await expect(fn).rejects.toThrow(ApiError) + await expect(fn).rejects.toThrow('id is null') + }) + it('requires a valid project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleRestoreDeletedProject(req, { id: 'doesnt-exist' }) + } + + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires ownership of the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleRestoreDeletedProject(req, { id: 'two' }) + } + + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('requires a deleted project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleRestoreDeletedProject(req, { id: 'two' }) + } + + await expect(fn).rejects.toThrow('Record to update not found') + }) + it('restores the project', async () => { + const fn = async () => { + const req = newTestRequest({ method: 'POST', authCookie: 'the-key' }) + return handleRestoreDeletedProject(req, { id: 'one' }) + } + + await fn() + const project = await prisma.project.findFirst({ where: { proj_id: 'one' } }) + expect(project?.deleted).toEqual(null) + }) +}) diff --git a/utopia-remix/app/routes/projects.$id.delete.tsx b/utopia-remix/app/routes/projects.$id.delete.tsx new file mode 100644 index 000000000000..26d8a335fdba --- /dev/null +++ b/utopia-remix/app/routes/projects.$id.delete.tsx @@ -0,0 +1,20 @@ +import { ActionFunctionArgs } from '@remix-run/node' +import { Params } from '@remix-run/react' +import { softDeleteProject } from '../models/project.server' +import { ensure, handle, requireUser } from '../util/api.server' +import { Status } from '../util/statusCodes.server' + +export async function action(args: ActionFunctionArgs) { + return handle(args, { POST: handleDeleteProject }) +} + +export async function handleDeleteProject(req: Request, params: Params) { + const user = await requireUser(req) + + const { id } = params + ensure(id != null, 'id is null', Status.BAD_REQUEST) + + await softDeleteProject({ id: id, userId: user.user_id }) + + return {} +} diff --git a/utopia-remix/app/routes/projects.$id.rename.tsx b/utopia-remix/app/routes/projects.$id.rename.tsx index c489ae083c64..2dd796666ea4 100644 --- a/utopia-remix/app/routes/projects.$id.rename.tsx +++ b/utopia-remix/app/routes/projects.$id.rename.tsx @@ -12,8 +12,6 @@ export async function action(args: ActionFunctionArgs) { } export async function handleRenameProject(req: Request, params: Params) { - ensure(req.method === 'POST', 'invalid method', Status.METHOD_NOT_ALLOWED) - const user = await requireUser(req) const { id } = params diff --git a/utopia-remix/app/routes/projects.$id.restore.tsx b/utopia-remix/app/routes/projects.$id.restore.tsx new file mode 100644 index 000000000000..fb9bd4ccd273 --- /dev/null +++ b/utopia-remix/app/routes/projects.$id.restore.tsx @@ -0,0 +1,20 @@ +import { ActionFunctionArgs } from '@remix-run/node' +import { Params } from '@remix-run/react' +import { restoreDeletedProject } from '../models/project.server' +import { ensure, handle, requireUser } from '../util/api.server' +import { Status } from '../util/statusCodes.server' + +export async function action(args: ActionFunctionArgs) { + return handle(args, { POST: handleRestoreDeletedProject }) +} + +export async function handleRestoreDeletedProject(req: Request, params: Params) { + const user = await requireUser(req) + + const { id } = params + ensure(id != null, 'id is null', Status.BAD_REQUEST) + + await restoreDeletedProject({ id: id, userId: user.user_id }) + + return {} +} diff --git a/utopia-remix/app/routes/projects.tsx b/utopia-remix/app/routes/projects.tsx index 007a5af53706..37eeee7a954c 100644 --- a/utopia-remix/app/routes/projects.tsx +++ b/utopia-remix/app/routes/projects.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useState } from 'react' import { LoaderFunctionArgs, json } from '@remix-run/node' import { useLoaderData } from '@remix-run/react' import moment from 'moment' import { UserDetails } from 'prisma-client' +import React, { useEffect, useState } from 'react' import { ProjectWithoutContent, listProjects } from '../models/project.server' -import { button } from '../styles/button.css' -import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' import { newProjectButton } from '../styles/newProjectButton.css' +import { projectCategoryButton, userName } from '../styles/sidebarComponents.css' import { sprinkles } from '../styles/sprinkles.css' import { requireUser } from '../util/api.server' diff --git a/utopia-remix/app/test-util.ts b/utopia-remix/app/test-util.ts index 16975edd451a..dfead6e7ef15 100644 --- a/utopia-remix/app/test-util.ts +++ b/utopia-remix/app/test-util.ts @@ -27,6 +27,7 @@ export async function createTestProject( content?: string createdAt?: Date modifiedAt?: Date + deleted?: boolean }, ) { const now = new Date() @@ -41,6 +42,7 @@ export async function createTestProject( created_at: params.createdAt ?? now, modified_at: params.modifiedAt ?? now, content: Buffer.from(params.content ?? 'test'), + deleted: params.deleted ?? null, }, }) }