diff --git a/apps/api/api-types/index.d.ts b/apps/api/api-types/index.d.ts new file mode 100644 index 000000000..62da44094 --- /dev/null +++ b/apps/api/api-types/index.d.ts @@ -0,0 +1,9 @@ +export namespace ApiTypes { + export { + GetProjectByIdApi, + DeleteProjectApi, + CreateProjectApi, + UpdateProjectNameApi, + UpdateProjectApi, + } from '../src/schemas'; +} diff --git a/apps/api/package.json b/apps/api/package.json index 0a2af55cd..cf5b36b5a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -6,6 +6,11 @@ "directories": { "test": "test" }, + "exports": { + "./api-types": { + "types": "./index.d.ts" + } + }, "scripts": { "test": "npm run build:ts && tsc -p test/tsconfig.json && tap --ts './test/**/*.test.ts'", "start": "npm run build:ts && fastify start -l info dist/app.js", @@ -21,19 +26,21 @@ "license": "ISC", "dependencies": { "@codeimage/prisma-models": "workspace:*", - "@fastify/autoload": "^5.0.0", - "@fastify/env": "4.0.0", - "@fastify/sensible": "^4.1.0", - "@fastify/swagger": "7.4.1", + "@fastify/autoload": "^5.1.0", + "@fastify/env": "^4.0.0", + "@fastify/sensible": "^5.1.0", + "@fastify/swagger": "^7.4.1", + "@fastify/type-provider-typebox": "^2.2.0", "@prisma/client": "^4.1.1", - "dotenv-cli": "6.0.0", - "fastify": "^4.0.0", + "@sinclair/typebox": "^0.24.26", + "dotenv-cli": "^6.0.0", + "fastify": "^4.3.0", "fastify-cli": "^4.4.0", - "fastify-plugin": "^3.0.0", - "fluent-json-schema": "3.1.0", - "graphql": "16.5.0", - "mercurius": "10.1.0", - "mercurius-codegen": "4.0.1", + "fastify-plugin": "^4.0.0", + "fluent-json-schema": "^3.1.0", + "graphql": "^16.5.0", + "mercurius": "^10.1.0", + "mercurius-codegen": "^4.0.1", "prisma": "4.1.1" }, "devDependencies": { diff --git a/apps/api/prisma/migrations/20220801191000_project_required_relationship/migration.sql b/apps/api/prisma/migrations/20220801191000_project_required_relationship/migration.sql new file mode 100644 index 000000000..11d32431e --- /dev/null +++ b/apps/api/prisma/migrations/20220801191000_project_required_relationship/migration.sql @@ -0,0 +1,63 @@ +/* + Warnings: + + - You are about to drop the column `projectId` on the `SnippetEditorOptions` table. All the data in the column will be lost. + - You are about to drop the column `projectId` on the `SnippetFrame` table. All the data in the column will be lost. + - You are about to drop the column `projectId` on the `SnippetTerminal` table. All the data in the column will be lost. + - A unique constraint covering the columns `[frameId]` on the table `Project` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[terminalId]` on the table `Project` will be added. If there are existing duplicate values, this will fail. + - A unique constraint covering the columns `[editorOptionsId]` on the table `Project` will be added. If there are existing duplicate values, this will fail. + - Added the required column `editorOptionsId` to the `Project` table without a default value. This is not possible if the table is not empty. + - Added the required column `frameId` to the `Project` table without a default value. This is not possible if the table is not empty. + - Added the required column `terminalId` to the `Project` table without a default value. This is not possible if the table is not empty. + +*/ +-- DropForeignKey +ALTER TABLE "SnippetEditorOptions" DROP CONSTRAINT "SnippetEditorOptions_projectId_fkey"; + +-- DropForeignKey +ALTER TABLE "SnippetFrame" DROP CONSTRAINT "SnippetFrame_projectId_fkey"; + +-- DropForeignKey +ALTER TABLE "SnippetTerminal" DROP CONSTRAINT "SnippetTerminal_projectId_fkey"; + +-- DropIndex +DROP INDEX "SnippetEditorOptions_projectId_key"; + +-- DropIndex +DROP INDEX "SnippetFrame_projectId_key"; + +-- DropIndex +DROP INDEX "SnippetTerminal_projectId_key"; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "editorOptionsId" TEXT NOT NULL, +ADD COLUMN "frameId" TEXT NOT NULL, +ADD COLUMN "terminalId" TEXT NOT NULL; + +-- AlterTable +ALTER TABLE "SnippetEditorOptions" DROP COLUMN "projectId"; + +-- AlterTable +ALTER TABLE "SnippetFrame" DROP COLUMN "projectId"; + +-- AlterTable +ALTER TABLE "SnippetTerminal" DROP COLUMN "projectId"; + +-- CreateIndex +CREATE UNIQUE INDEX "Project_frameId_key" ON "Project"("frameId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_terminalId_key" ON "Project"("terminalId"); + +-- CreateIndex +CREATE UNIQUE INDEX "Project_editorOptionsId_key" ON "Project"("editorOptionsId"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_frameId_fkey" FOREIGN KEY ("frameId") REFERENCES "SnippetFrame"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_terminalId_fkey" FOREIGN KEY ("terminalId") REFERENCES "SnippetTerminal"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_editorOptionsId_fkey" FOREIGN KEY ("editorOptionsId") REFERENCES "SnippetEditorOptions"("id") ON DELETE RESTRICT ON UPDATE CASCADE; diff --git a/apps/api/prisma/migrations/20220803210003_project_editors_unique_index/migration.sql b/apps/api/prisma/migrations/20220803210003_project_editors_unique_index/migration.sql new file mode 100644 index 000000000..9e88601f0 --- /dev/null +++ b/apps/api/prisma/migrations/20220803210003_project_editors_unique_index/migration.sql @@ -0,0 +1,11 @@ +/* + Warnings: + + - A unique constraint covering the columns `[id,projectId]` on the table `SnippetEditorTab` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropIndex +DROP INDEX "SnippetEditorTab_projectId_key"; + +-- CreateIndex +CREATE UNIQUE INDEX "SnippetEditorTab_id_projectId_key" ON "SnippetEditorTab"("id", "projectId"); diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma index f78e4c64a..73d05f18e 100644 --- a/apps/api/prisma/schema.prisma +++ b/apps/api/prisma/schema.prisma @@ -19,16 +19,19 @@ model User { } model Project { - id String @id @default(uuid()) - name String - frame SnippetFrame? - terminal SnippetTerminal? - editorOptions SnippetEditorOptions? - editorTabs SnippetEditorTab[] - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - user User @relation(fields: [userId], references: [id]) - userId String + id String @id @default(uuid()) + name String + frame SnippetFrame @relation(fields: [frameId], references: [id]) + terminal SnippetTerminal @relation(fields: [terminalId], references: [id]) + editorOptions SnippetEditorOptions @relation(fields: [editorOptionsId], references: [id]) + frameId String @unique + terminalId String @unique + editorOptionsId String @unique + editorTabs SnippetEditorTab[] + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + userId String @@unique([id, userId]) @@index([name, userId]) @@ -36,8 +39,7 @@ model Project { model SnippetFrame { id String @id @default(uuid()) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId String @unique + project Project? background String? padding Int? radius Int? @@ -47,8 +49,7 @@ model SnippetFrame { model SnippetTerminal { id String @id @default(uuid()) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId String @unique + project Project? showHeader Boolean? type String? accentVisible Boolean? @@ -63,8 +64,7 @@ model SnippetTerminal { model SnippetEditorOptions { id String @id @default(uuid()) - project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId String @unique + project Project? fontId String? fontWeight Int? showLineNumbers Boolean? @@ -74,8 +74,10 @@ model SnippetEditorOptions { model SnippetEditorTab { id String @id @default(uuid()) project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) - projectId String @unique + projectId String code String? languageId String? tabName String? + + @@unique([id, projectId]) } diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index d8f11e00b..dd5b7aa5a 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -15,18 +15,11 @@ const app: FastifyPluginAsync = async ( // This loads all plugins defined in plugins // those should be support plugins that are reused // through your application - void fastify.register(AutoLoad, { + await void fastify.register(AutoLoad, { dir: join(__dirname, 'plugins'), options: opts, }); - await fastify.register(AutoLoad, { - dir: join(__dirname, 'modules'), - options: opts, - encapsulate: false, - maxDepth: 1, - }); - // This loads all plugins defined in routes // define your routes in one of these await fastify.register(AutoLoad, { @@ -35,6 +28,13 @@ const app: FastifyPluginAsync = async ( routeParams: true, }); + await fastify.register(AutoLoad, { + dir: join(__dirname, 'modules'), + options: opts, + encapsulate: false, + maxDepth: 1, + }); + fastify.ready(() => fastify.log.info(`\n${fastify.printRoutes()}`)); }; diff --git a/apps/api/src/common/types/extract-api-types.ts b/apps/api/src/common/types/extract-api-types.ts new file mode 100644 index 000000000..8ee37617d --- /dev/null +++ b/apps/api/src/common/types/extract-api-types.ts @@ -0,0 +1,24 @@ +import {Static, TSchema} from '@sinclair/typebox'; +import {FastifySchema} from 'fastify'; + +type StaticSchemaOrType = T extends TSchema ? Static : T; + +type GetApiRequest = { + body?: StaticSchemaOrType; + querystring?: StaticSchemaOrType; + params?: StaticSchemaOrType; + headers?: StaticSchemaOrType; +}; + +type GetApiResponse = T['response'] extends { + 200?: infer U; +} + ? U extends TSchema + ? Static + : U + : never; + +export interface GetApiTypes { + request: GetApiRequest; + response: GetApiResponse; +} diff --git a/apps/api/src/modules/project/domain/index.ts b/apps/api/src/modules/project/domain/index.ts index 1f85d8974..c97dbb9ad 100644 --- a/apps/api/src/modules/project/domain/index.ts +++ b/apps/api/src/modules/project/domain/index.ts @@ -1,2 +1,3 @@ export * from './projectCreateRequest'; export * from './projectDeleteRequest'; +export * from './projectUpdateRequest'; diff --git a/apps/api/src/modules/project/domain/projectUpdateRequest.ts b/apps/api/src/modules/project/domain/projectUpdateRequest.ts new file mode 100644 index 000000000..f444fe000 --- /dev/null +++ b/apps/api/src/modules/project/domain/projectUpdateRequest.ts @@ -0,0 +1,23 @@ +import { + Project, + SnippetEditorOptions, + SnippetEditorTab, + SnippetFrame, + SnippetTerminal, +} from '@codeimage/prisma-models/*'; + +type IdAndProjectId = 'id' | 'projectId'; + +export interface ProjectUpdateRequest { + editorOptions: Omit; + terminal: Omit; + frame: Omit; + editors: SnippetEditorTab[]; +} + +export type ProjectUpdateResponse = Project & { + editorOptions: SnippetEditorOptions | null; + terminal: SnippetTerminal | null; + frame: SnippetFrame | null; + editorTabs: SnippetEditorTab[]; +}; diff --git a/apps/api/src/modules/project/index.ts b/apps/api/src/modules/project/index.ts index 4f6fff507..fe7444bc2 100644 --- a/apps/api/src/modules/project/index.ts +++ b/apps/api/src/modules/project/index.ts @@ -1,17 +1,12 @@ import {FastifyPluginAsync} from 'fastify'; import {makePrismaProjectRepository} from './infra/prisma/prisma-project.repository'; import {ProjectRepository} from './repository'; -import * as projectSchemas from './schema'; export const project: FastifyPluginAsync = async fastify => { fastify.decorate( 'projectRepository', makePrismaProjectRepository(fastify.prisma), ); - - for (const schema of Object.values(projectSchemas)) { - fastify.addSchema(schema); - } }; declare module 'fastify' { diff --git a/apps/api/src/modules/project/infra/prisma/prisma-project.repository.ts b/apps/api/src/modules/project/infra/prisma/prisma-project.repository.ts index c3407373c..4f4e7d3cf 100644 --- a/apps/api/src/modules/project/infra/prisma/prisma-project.repository.ts +++ b/apps/api/src/modules/project/infra/prisma/prisma-project.repository.ts @@ -1,10 +1,29 @@ import {PrismaClient, Project} from '@codeimage/prisma-models'; -import {ProjectCreateRequest, ProjectCreateResponse} from '../../domain'; +import { + ProjectCreateRequest, + ProjectCreateResponse, + ProjectUpdateRequest, + ProjectUpdateResponse, +} from '../../domain'; import {ProjectRepository} from '../../repository'; export function makePrismaProjectRepository( client: PrismaClient, ): ProjectRepository { + function findById(id: string): Promise { + return client.project.findFirstOrThrow({ + where: { + id, + }, + include: { + editorTabs: true, + terminal: true, + editorOptions: true, + frame: true, + }, + }); + } + function findAllByUserId(userId: string): Promise { return client.project.findMany({ where: { @@ -37,7 +56,14 @@ export function makePrismaProjectRepository( return client.project.create({ data: { name: 'Untitled', - userId, + user: { + connect: {id: userId}, + }, + editorTabs: { + createMany: { + data: data.editors, + }, + }, editorOptions: { create: { fontId: data.editorOptions.fontId, @@ -46,13 +72,86 @@ export function makePrismaProjectRepository( themeId: data.editorOptions.themeId, }, }, + frame: { + create: { + background: data.frame.background, + opacity: data.frame.opacity, + radius: data.frame.radius, + padding: data.frame.padding, + visible: data.frame.visible, + }, + }, + terminal: { + create: { + accentVisible: data.terminal.accentVisible, + alternativeTheme: data.terminal.alternativeTheme, + background: data.terminal.background, + opacity: data.terminal.opacity, + shadow: data.terminal.shadow, + showGlassReflection: data.terminal.showGlassReflection, + showHeader: data.terminal.showHeader, + showWatermark: data.terminal.showWatermark, + textColor: data.terminal.textColor, + type: data.terminal.type, + }, + }, + }, + include: { + editorOptions: true, + editorTabs: true, + frame: true, + terminal: true, + }, + }); + } + + async function updateProject( + userId: string, + projectId: string, + data: ProjectUpdateRequest, + ): Promise { + return client.project.update({ + where: { + id: projectId, + }, + data: { editorTabs: { - createMany: { - data: data.editors, + deleteMany: { + NOT: { + id: { + in: data.editors.map(({id}) => id), + }, + }, + }, + upsert: data.editors.map(editor => { + const {languageId, code, tabName} = editor; + return { + where: { + id: editor.id, + }, + create: { + code, + tabName, + languageId, + }, + update: { + code, + tabName, + languageId, + }, + }; + }), + }, + editorOptions: { + update: { + fontId: data.editorOptions.fontId, + fontWeight: data.editorOptions.fontWeight, + showLineNumbers: data.editorOptions.showLineNumbers, + themeId: data.editorOptions.themeId, }, }, frame: { - create: { + update: { background: data.frame.background, opacity: data.frame.opacity, radius: data.frame.radius, @@ -61,7 +160,7 @@ export function makePrismaProjectRepository( }, }, terminal: { - create: { + update: { accentVisible: data.terminal.accentVisible, alternativeTheme: data.terminal.alternativeTheme, background: data.terminal.background, @@ -84,9 +183,23 @@ export function makePrismaProjectRepository( }); } + function updateProjectName(userId: string, projectId: string, name: string) { + return client.project.update({ + data: { + name, + }, + where: { + id: projectId, + }, + }); + } + return { + findById, + updateProjectName, findAllByUserId, createNewProject, + updateProject, deleteProject, }; } diff --git a/apps/api/src/modules/project/repository/project.repository.ts b/apps/api/src/modules/project/repository/project.repository.ts index 7b66cf6ad..5cde5e835 100644 --- a/apps/api/src/modules/project/repository/project.repository.ts +++ b/apps/api/src/modules/project/repository/project.repository.ts @@ -1,12 +1,31 @@ import {Project} from '@codeimage/prisma-models'; -import type {ProjectCreateRequest, ProjectCreateResponse} from '../domain'; +import type { + ProjectCreateRequest, + ProjectUpdateRequest, + ProjectUpdateResponse, +} from '../domain'; +import {ProjectCreateResponse} from '../domain'; export interface ProjectRepository { + findById(id: string): Promise; + + updateProjectName( + userId: string, + projectId: string, + newName: string, + ): Promise; + createNewProject( userId: string, data: ProjectCreateRequest, ): Promise; + updateProject( + userId: string, + projectId: string, + data: ProjectUpdateRequest, + ): Promise; + deleteProject(id: string, userId: string): Promise; findAllByUserId(userId: string): Promise; diff --git a/apps/api/src/modules/project/schema/index.ts b/apps/api/src/modules/project/schema/index.ts index bc60c9349..0967a30fe 100644 --- a/apps/api/src/modules/project/schema/index.ts +++ b/apps/api/src/modules/project/schema/index.ts @@ -1,4 +1,22 @@ export { - projectCreateRequestSchema, - projectCreateResponseSchema, + ProjectCreateRequestSchema, + ProjectCreateRequest, + ProjectCreateResponse, + ProjectCreateResponseSchema, } from './project-create.schema'; + +export { + ProjectUpdateRequest, + ProjectUpdateResponse, + ProjectUpdateRequestSchema, + ProjectUpdateResponseSchema, +} from './project-update.schema'; + +export { + ProjectDeleteResponseSchema, + ProjectDeleteResponse, +} from './project-delete.schema'; +export { + ProjectGetByIdResponseSchema, + ProjectGetByIdResponse, +} from './project-get-by-id.schema'; diff --git a/apps/api/src/modules/project/schema/project-create.schema.ts b/apps/api/src/modules/project/schema/project-create.schema.ts index e162a2774..adcdf022d 100644 --- a/apps/api/src/modules/project/schema/project-create.schema.ts +++ b/apps/api/src/modules/project/schema/project-create.schema.ts @@ -1,95 +1,87 @@ -import S from 'fluent-json-schema'; +import {Static, TSchema, Type} from '@sinclair/typebox'; -const editorOptionsCreateRequest = S.object() - .id('editorOptionsCreateRequest') - .prop( - 'fontId', - S.string().description('The font id of the snippet').required(), - ) - .prop('fontWeight', S.number().description('The font weight of the snippet')) - .prop( - 'showLineNumbers', - S.boolean().description('Show/hide the editor line numbers'), - ) - .prop('themeId', S.string().description('The theme id of the snippet')); +const Nullable = (type: T) => + Type.Union([type, Type.Null()]); -const snippetFrameCreateRequest = S.object() - .id('snippetFrameCreateRequest') - .prop( - 'background', - S.string().description('The background color of the frame'), - ) - .prop('opacity', S.number().description('The opacity of the frame')) - .prop('radius', S.number().description('The radius of the frame')) - .prop('padding', S.number().description('The padding of the frame')) - .prop('visible', S.boolean().description('Show/hide the background frame')); +export const SnippetFrameCreateRequestSchema = Type.Object( + { + background: Nullable(Type.String()), + opacity: Nullable(Type.Number()), + radius: Nullable(Type.Number()), + padding: Nullable(Type.Number()), + visible: Nullable(Type.Boolean()), + }, + { + $id: 'SnippetFrameCreateRequest', + }, +); -const snippetEditorTabsCreateRequest = S.array() - .id('snippetEditorTabsCreateRequest') - .items( - S.object() - .prop('code', S.string().description('The code of the tab')) - .prop( - 'languageId', - S.string().description( - 'The CodeMirror6 language package id of the tab', - ), - ) - .prop('tabName', S.string().description('The name of the tab')), - ); +export const SnippetEditorTabsCreateRequestSchema = Type.Array( + Type.Object( + { + code: Nullable(Type.String()), + languageId: Nullable(Type.String()), + tabName: Nullable(Type.String()), + }, + {$id: 'SnippetEditorTabCreateRequest'}, + ), + {$id: 'SnippetEditorTabsCreateRequest'}, +); -const snippetTerminalCreateRequest = S.object() - .id('snippetTerminalCreateRequest') - .prop( - 'accentVisible', - S.boolean().description('Show/hide the terminal accent tab'), - ) - .prop( - 'alternativeTheme', - S.boolean().description('Use the alternative theme'), - ) - .prop( - 'background', - S.string().description('The background color of the terminal'), - ) - .prop('opacity', S.number().description('The opacity of the terminal')) - .prop('shadow', S.string().description('The shadow of the editor frame')) - .prop( - 'showGlassReflection', - S.boolean().description('Show/hide the glass reflection'), - ) - .prop('showHeader', S.boolean().description('Show/hide the terminal header')) - .prop('showWatermark', S.boolean().description('Show/hide the watermark')) - .prop('textColor', S.string().description('The text color of the terminal')) - .prop('type', S.string().description('The type of the terminal')); +const SnippetTerminalCreateRequestSchema = Type.Object( + { + accentVisible: Nullable(Type.Boolean()), + alternativeTheme: Nullable(Type.Boolean()), + background: Nullable(Type.String()), + opacity: Nullable(Type.Number()), + shadow: Nullable(Type.String()), + showGlassReflection: Nullable(Type.Boolean()), + showHeader: Nullable(Type.Boolean()), + showWatermark: Nullable(Type.Boolean()), + textColor: Nullable(Type.String()), + type: Nullable(Type.String()), + }, + {$id: 'SnippetTerminalCreateRequest'}, +); -export const projectCreateRequestSchema = S.object() - .id('projectCreateRequest') - .prop('name', S.string().description('The name of the snippet').required()) - .prop('editorOptions', editorOptionsCreateRequest) - .required() - .prop('frame', snippetFrameCreateRequest) - .required() - .prop('terminal', snippetTerminalCreateRequest) - .required() - .prop('editors', snippetEditorTabsCreateRequest) - .required(); +const EditorOptionsCreateRequestSchema = Type.Object( + { + fontId: Nullable(Type.String()), + fontWeight: Nullable(Type.Number()), + showLineNumbers: Nullable(Type.Boolean()), + themeId: Nullable(Type.String()), + }, + { + $id: 'EditorOptionsCreateRequest', + }, +); -export const projectCreateResponseSchema = S.object() - .id('projectCreateResponse') - .prop('id', S.string().description('The id of the snippet')) - .prop('createdAt', S.string().description('The creation date of the snippet')) - .prop( - 'updatedAt', - S.string().description('The last update date of the snippet'), - ) - .prop('name', S.string().description('The name of the snippet')) - .required() - .prop('editorOptions', editorOptionsCreateRequest) - .required() - .prop('frame', snippetFrameCreateRequest) - .required() - .prop('terminal', snippetTerminalCreateRequest) - .required() - .prop('editorTabs', snippetEditorTabsCreateRequest) - .required(); +export const ProjectCreateRequestSchema = Type.Object( + { + name: Type.String(), + editorOptions: EditorOptionsCreateRequestSchema, + frame: SnippetFrameCreateRequestSchema, + terminal: SnippetTerminalCreateRequestSchema, + editors: SnippetEditorTabsCreateRequestSchema, + }, + {$id: 'ProjectCreateRequest'}, +); + +export const ProjectCreateResponseSchema = Type.Object( + { + id: Type.String(), + createdAt: Type.String({format: 'date-time'}), + updatedAt: Type.String({format: 'date-time'}), + name: Type.String(), + editorOptions: Type.Required(EditorOptionsCreateRequestSchema), + frame: Type.Required(SnippetFrameCreateRequestSchema), + terminal: Type.Required(SnippetTerminalCreateRequestSchema), + editorTabs: SnippetEditorTabsCreateRequestSchema, + }, + { + $id: 'ProjectCreateResponse', + }, +); + +export type ProjectCreateRequest = Static; +export type ProjectCreateResponse = Static; diff --git a/apps/api/src/modules/project/schema/project-delete.schema.ts b/apps/api/src/modules/project/schema/project-delete.schema.ts new file mode 100644 index 000000000..8b94aa8a3 --- /dev/null +++ b/apps/api/src/modules/project/schema/project-delete.schema.ts @@ -0,0 +1,12 @@ +import {Static, Type} from '@sinclair/typebox'; +import {BaseProjectResponseSchema} from './project.schema'; + +export const ProjectDeleteResponseSchema = Type.Intersect( + [BaseProjectResponseSchema], + { + $id: 'ProjectDeleteResponse', + title: 'ProjectDeleteResponse', + }, +); + +export type ProjectDeleteResponse = Static; diff --git a/apps/api/src/modules/project/schema/project-get-by-id.schema.ts b/apps/api/src/modules/project/schema/project-get-by-id.schema.ts new file mode 100644 index 000000000..12b6b1154 --- /dev/null +++ b/apps/api/src/modules/project/schema/project-get-by-id.schema.ts @@ -0,0 +1,22 @@ +import {Static, Type} from '@sinclair/typebox'; +import { + BaseProjectResponseSchema, + BaseSnippetEditorTabsSchema, + BaseSnippetFrameSchema, + BaseSnippetTerminalSchema, + BaseSnippetEditorOptionsSchema, +} from './project.schema'; + +export const ProjectGetByIdResponseSchema = Type.Intersect([ + BaseProjectResponseSchema, + Type.Object({ + editorTabs: BaseSnippetEditorTabsSchema, + editorOptions: BaseSnippetEditorOptionsSchema, + frame: BaseSnippetFrameSchema, + terminal: BaseSnippetTerminalSchema, + }), +]); + +export type ProjectGetByIdResponse = Static< + typeof ProjectGetByIdResponseSchema +>; diff --git a/apps/api/src/modules/project/schema/project-update.schema.ts b/apps/api/src/modules/project/schema/project-update.schema.ts new file mode 100644 index 000000000..74da843ad --- /dev/null +++ b/apps/api/src/modules/project/schema/project-update.schema.ts @@ -0,0 +1,86 @@ +import {Static, TSchema, Type} from '@sinclair/typebox'; + +const Nullable = (type: T) => + Type.Union([type, Type.Null()]); + +export const SnippetFrameUpdateRequestSchema = Type.Object( + { + background: Nullable(Type.String()), + opacity: Nullable(Type.Number()), + radius: Nullable(Type.Number()), + padding: Nullable(Type.Number()), + visible: Nullable(Type.Boolean()), + }, + { + $id: 'SnippetFrameUpdateRequest', + }, +); + +export const SnippetEditorTabsUpdateRequestSchema = Type.Array( + Type.Object( + { + code: Nullable(Type.String()), + languageId: Nullable(Type.String()), + tabName: Nullable(Type.String()), + }, + {$id: 'SnippetEditorTabUpdateRequest'}, + ), + {$id: 'SnippetEditorTabsUpdateRequest'}, +); + +const SnippetTerminalUpdateRequestSchema = Type.Object( + { + accentVisible: Nullable(Type.Boolean()), + alternativeTheme: Nullable(Type.Boolean()), + background: Nullable(Type.String()), + opacity: Nullable(Type.Number()), + shadow: Nullable(Type.String()), + showGlassReflection: Nullable(Type.Boolean()), + showHeader: Nullable(Type.Boolean()), + showWatermark: Nullable(Type.Boolean()), + textColor: Nullable(Type.String()), + type: Nullable(Type.String()), + }, + {$id: 'SnippetTerminalUpdateRequest'}, +); + +const EditorOptionsUpdateRequestSchema = Type.Object( + { + fontId: Nullable(Type.String()), + fontWeight: Nullable(Type.Number()), + showLineNumbers: Nullable(Type.Boolean()), + themeId: Nullable(Type.String()), + }, + { + $id: 'EditorOptionsUpdateRequest', + }, +); + +export const ProjectUpdateRequestSchema = Type.Object( + { + editorOptions: EditorOptionsUpdateRequestSchema, + frame: SnippetFrameUpdateRequestSchema, + terminal: SnippetTerminalUpdateRequestSchema, + editors: SnippetEditorTabsUpdateRequestSchema, + }, + {$id: 'ProjectUpdateRequest'}, +); + +export const ProjectUpdateResponseSchema = Type.Object( + { + id: Type.String(), + createdAt: Type.String({format: 'date-time'}), + updatedAt: Type.String({format: 'date-time'}), + name: Type.String(), + editorOptions: Type.Required(EditorOptionsUpdateRequestSchema), + frame: Type.Required(SnippetFrameUpdateRequestSchema), + terminal: Type.Required(SnippetTerminalUpdateRequestSchema), + editorTabs: SnippetEditorTabsUpdateRequestSchema, + }, + { + $id: 'ProjectUpdateResponse', + }, +); + +export type ProjectUpdateRequest = Static; +export type ProjectUpdateResponse = Static; diff --git a/apps/api/src/modules/project/schema/project.schema.ts b/apps/api/src/modules/project/schema/project.schema.ts new file mode 100644 index 000000000..87049d656 --- /dev/null +++ b/apps/api/src/modules/project/schema/project.schema.ts @@ -0,0 +1,56 @@ +import {Type} from '@sinclair/typebox'; + +export const BaseProjectResponseSchema = Type.Object( + { + id: Type.String(), + name: Type.String(), + createdAt: Type.String({format: 'date-time'}), + updatedAt: Type.String({format: 'date-time'}), + userId: Type.String({format: 'uuid'}), + }, + { + $id: 'BaseProjectResponse', + title: 'BaseProjectResponse', + }, +); + +export const BaseSnippetEditorTabsSchema = Type.Array( + Type.Object({ + id: Type.String({format: 'uuid'}), + projectId: Type.String({format: 'uuid'}), + code: Type.String(), + languageId: Type.String(), + tabName: Type.String(), + }), +); + +export const BaseSnippetFrameSchema = Type.Object({ + id: Type.String({format: 'uuid'}), + background: Type.String(), + padding: Type.Number(), + radius: Type.Number(), + visible: Type.Boolean(), + opacity: Type.Number(), +}); + +export const BaseSnippetTerminalSchema = Type.Object({ + id: Type.String({format: 'uuid'}), + showHeader: Type.Boolean(), + type: Type.String(), + accentVisible: Type.Boolean(), + shadow: Type.String(), + background: Type.String(), + textColor: Type.String(), + showWatermark: Type.Boolean(), + showGlassReflection: Type.Boolean(), + opacity: Type.Number(), + alternativeTheme: Type.Boolean(), +}); + +export const BaseSnippetEditorOptionsSchema = Type.Object({ + id: Type.String({format: 'uuid'}), + fontId: Type.String(), + fontWeight: Type.Number(), + showLineNumbers: Type.Boolean(), + themeId: Type.String(), +}); diff --git a/apps/api/src/modules/workspace/handlers.ts b/apps/api/src/modules/workspace/handlers.ts deleted file mode 100644 index 5ffe488d0..000000000 --- a/apps/api/src/modules/workspace/handlers.ts +++ /dev/null @@ -1,92 +0,0 @@ -// import { -// PrismaClient, -// Project, -// SnippetEditorOptions, -// SnippetEditorTab, -// SnippetFrame, -// SnippetTerminal, -// } from '@codeimage/prisma-models'; -// -// type IdAndSnippetId = 'id' | 'snippetId'; -// -// export interface ProjectCreateRequest { -// name: Project['name']; -// editorOptions: Omit; -// terminal: Omit; -// frame: Omit; -// editors: Omit[]; -// } -// -// export type ProjectCreateResponse = Project & { -// editorOptions: SnippetEditorOptions | null; -// terminal: SnippetTerminal | null; -// frame: SnippetFrame | null; -// editorTabs: SnippetEditorTab[]; -// }; -// -// export type CreateProjectHandler = ( -// projectModel: PrismaClient['project'], -// data: ProjectCreateRequest, -// userId: string, -// ) => Promise; -// -// export const createProject: CreateProjectHandler = ( -// projectModel, -// data, -// userId, -// ) => { -// return projectModel.create({ -// data: { -// name: 'Untitled', -// userId, -// editorOptions: { -// create: { -// fontId: data.editorOptions.fontId, -// fontWeight: data.editorOptions.fontWeight, -// showLineNumbers: data.editorOptions.showLineNumbers, -// themeId: data.editorOptions.themeId, -// }, -// }, -// editorTabs: { -// createMany: { -// data: data.editors, -// }, -// }, -// frame: { -// create: { -// background: data.frame.background, -// opacity: data.frame.opacity, -// radius: data.frame.radius, -// padding: data.frame.padding, -// visible: data.frame.visible, -// }, -// }, -// terminal: { -// create: { -// accentVisible: data.terminal.accentVisible, -// alternativeTheme: data.terminal.alternativeTheme, -// background: data.terminal.background, -// opacity: data.terminal.opacity, -// shadow: data.terminal.shadow, -// showGlassReflection: data.terminal.showGlassReflection, -// showHeader: data.terminal.showHeader, -// showWatermark: data.terminal.showWatermark, -// textColor: data.terminal.textColor, -// type: data.terminal.type, -// }, -// }, -// }, -// include: { -// editorOptions: true, -// editorTabs: true, -// frame: true, -// terminal: true, -// }, -// }); -// }; - -import fp from 'fastify-plugin'; - -export default fp(async () => { - void 0; -}); diff --git a/apps/api/src/modules/workspace/index.ts b/apps/api/src/modules/workspace/index.ts deleted file mode 100644 index 6770cf427..000000000 --- a/apps/api/src/modules/workspace/index.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { - Project, - SnippetEditorOptions, - SnippetEditorTab, - SnippetFrame, - SnippetTerminal, -} from '@codeimage/prisma-models'; -import fp from 'fastify-plugin'; - -interface ProjectHandler { - findAllByUserId(id: string): Promise; - - deleteById(id: string, userId: string): Promise; - - create(data: ProjectCreateRequest, userId: string): Promise; - - updateById(data: Project): Promise; -} - -type IdAndSnippetId = 'id' | 'snippetId'; - -export interface ProjectCreateRequest { - name: Project['name']; - editorOptions: Omit; - terminal: Omit; - frame: Omit; - editors: Omit[]; -} - -export default fp(async fastify => { - const handler: ProjectHandler = { - findAllByUserId(id: string): Promise { - return fastify.prisma.project.findMany({ - where: { - userId: id, - }, - }); - }, - - deleteById(id: string, userId: string): Promise { - return fastify.prisma.project.delete({ - where: { - id_userId: { - id, - userId, - }, - }, - }); - }, - - create(data: ProjectCreateRequest, userId: string) { - return fastify.prisma.project.create({ - data: { - name: 'Untitled', - userId, - editorOptions: { - create: { - fontId: data.editorOptions.fontId, - fontWeight: data.editorOptions.fontWeight, - showLineNumbers: data.editorOptions.showLineNumbers, - themeId: data.editorOptions.themeId, - }, - }, - editorTabs: { - createMany: { - data: data.editors, - }, - }, - frame: { - create: { - background: data.frame.background, - opacity: data.frame.opacity, - radius: data.frame.radius, - padding: data.frame.padding, - visible: data.frame.visible, - }, - }, - terminal: { - create: { - accentVisible: data.terminal.accentVisible, - alternativeTheme: data.terminal.alternativeTheme, - background: data.terminal.background, - opacity: data.terminal.opacity, - shadow: data.terminal.shadow, - showGlassReflection: data.terminal.showGlassReflection, - showHeader: data.terminal.showHeader, - showWatermark: data.terminal.showWatermark, - textColor: data.terminal.textColor, - type: data.terminal.type, - }, - }, - }, - include: { - editorOptions: true, - editorTabs: true, - frame: true, - terminal: true, - }, - }); - }, - - updateById(data: Project): Promise { - return fastify.prisma.project.update({ - where: { - id: data.id, - }, - data, - }); - }, - }; - - fastify.decorate('workspace', handler); -}); - -declare module 'fastify' { - interface FastifyInstance { - workspace: ProjectHandler; - } -} diff --git a/apps/api/src/modules/workspace/workspace.schema.ts b/apps/api/src/modules/workspace/workspace.schema.ts deleted file mode 100644 index 803c39722..000000000 --- a/apps/api/src/modules/workspace/workspace.schema.ts +++ /dev/null @@ -1,92 +0,0 @@ -import S from 'fluent-json-schema'; - -const editorOptionsCreateRequest = S.object() - .id('editorOptionsCreateRequest') - .prop('fontId', S.string().description('The font id of the snippet')) - .prop('fontWeight', S.number().description('The font weight of the snippet')) - .prop( - 'showLineNumbers', - S.boolean().description('Show/hide the editor line numbers'), - ) - .prop('themeId', S.string().description('The theme id of the snippet')); - -const snippetFrameCreateRequest = S.object() - .id('snippetFrameCreateRequest') - .prop( - 'background', - S.string().description('The background color of the frame'), - ) - .prop('opacity', S.number().description('The opacity of the frame')) - .prop('radius', S.number().description('The radius of the frame')) - .prop('padding', S.number().description('The padding of the frame')) - .prop('visible', S.boolean().description('Show/hide the background frame')); - -const snippetEditorTabsCreateRequest = S.array() - .id('snippetEditorTabsCreateRequest') - .items( - S.object() - .prop('code', S.string().description('The code of the tab')) - .prop( - 'languageId', - S.string().description( - 'The CodeMirror6 language package id of the tab', - ), - ) - .prop('tabName', S.string().description('The name of the tab')), - ); - -const snippetTerminalCreateRequest = S.object() - .id('snippetTerminalCreateRequest') - .prop( - 'accentVisible', - S.boolean().description('Show/hide the terminal accent tab'), - ) - .prop( - 'alternativeTheme', - S.boolean().description('Use the alternative theme'), - ) - .prop( - 'background', - S.string().description('The background color of the terminal'), - ) - .prop('opacity', S.number().description('The opacity of the terminal')) - .prop('shadow', S.string().description('The shadow of the editor frame')) - .prop( - 'showGlassReflection', - S.boolean().description('Show/hide the glass reflection'), - ) - .prop('showHeader', S.boolean().description('Show/hide the terminal header')) - .prop('showWatermark', S.boolean().description('Show/hide the watermark')) - .prop('textColor', S.string().description('The text color of the terminal')) - .prop('type', S.string().description('The type of the terminal')); - -export const workspaceCreateRequestSchema = S.object() - .id('workspaceCreateRequest') - .prop('name', S.string().description('The name of the snippet').required()) - .prop('editorOptions', editorOptionsCreateRequest) - .required() - .prop('frame', snippetFrameCreateRequest) - .required() - .prop('terminal', snippetTerminalCreateRequest) - .required() - .prop('editors', snippetEditorTabsCreateRequest) - .required(); - -export const workspaceCreateResponseSchema = S.object() - .id('workspaceCreateResponse') - .prop('id', S.string().description('The id of the snippet')) - .prop('createdAt', S.string().description('The creation date of the snippet')) - .prop( - 'updatedAt', - S.string().description('The last update date of the snippet'), - ) - .prop('name', S.string().description('The name of the snippet')) - .required() - .prop('editorOptions', editorOptionsCreateRequest) - .required() - .prop('snippetFrame', snippetFrameCreateRequest) - .required() - .prop('terminal', snippetTerminalCreateRequest) - .required() - .prop('editorTabs', snippetEditorTabsCreateRequest) - .required(); diff --git a/apps/api/src/plugins/swagger.ts b/apps/api/src/plugins/swagger.ts index fcb1a049f..cb3779083 100644 --- a/apps/api/src/plugins/swagger.ts +++ b/apps/api/src/plugins/swagger.ts @@ -15,7 +15,6 @@ export default fp(async fastify => { schemes: ['http'], consumes: ['application/json'], produces: ['application/json'], - tags: [{name: 'Workspace', description: 'Workspace'}], }, uiConfig: { docExpansion: 'full', diff --git a/apps/api/src/routes/v1/project/create.ts b/apps/api/src/routes/v1/project/create.ts index 84e2f1ca7..42d7bc396 100644 --- a/apps/api/src/routes/v1/project/create.ts +++ b/apps/api/src/routes/v1/project/create.ts @@ -1,19 +1,22 @@ -import {FastifyPluginAsync, FastifySchema} from 'fastify'; -import {ProjectCreateRequest} from '../../../modules/workspace'; +import {FastifyPluginAsync} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types'; +import { + ProjectCreateRequest, + ProjectCreateRequestSchema, + ProjectCreateResponseSchema, +} from '../../../modules/project/schema'; -const schema: FastifySchema = { - tags: ['Workspace'], +const schema = { + tags: ['Project'], description: 'Create a new CodeImage project', - body: { - $ref: 'projectCreateRequest', - }, + body: ProjectCreateRequestSchema, response: { - 200: { - $ref: 'projectCreateResponse', - }, + 200: ProjectCreateResponseSchema, }, }; +export type CreateProjectApi = GetApiTypes; + const createRoute: FastifyPluginAsync = async fastify => { fastify.post<{ Body: ProjectCreateRequest; diff --git a/apps/api/src/routes/v1/project/delete.ts b/apps/api/src/routes/v1/project/delete.ts index 8880ef132..12e7834b6 100644 --- a/apps/api/src/routes/v1/project/delete.ts +++ b/apps/api/src/routes/v1/project/delete.ts @@ -1,15 +1,38 @@ -import {FastifyPluginAsync} from 'fastify'; +import {Type} from '@sinclair/typebox'; +import {FastifyPluginAsync, FastifySchema} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types'; +import {ProjectDeleteResponseSchema} from '../../../modules/project/schema'; + +const schema = { + tags: ['Project'], + params: Type.Object({ + id: Type.String(), + }), + response: { + 200: ProjectDeleteResponseSchema, + }, +}; + +export type DeleteProjectApi = GetApiTypes; const deleteRoute: FastifyPluginAsync = async fastify => { + const schema: FastifySchema = { + tags: ['Project'], + params: Type.Object({ + id: Type.String(), + }), + response: { + 200: ProjectDeleteResponseSchema, + }, + }; + fastify.delete<{ Params: {id: string}; }>( '/:id', { preHandler: fastify.authorize, - schema: { - tags: ['Workspace'], - }, + schema, }, async request => { const { diff --git a/apps/api/src/routes/v1/project/getAllByUserId.ts b/apps/api/src/routes/v1/project/getAllByUserId.ts index 2d1b7d3f7..20103bebb 100644 --- a/apps/api/src/routes/v1/project/getAllByUserId.ts +++ b/apps/api/src/routes/v1/project/getAllByUserId.ts @@ -4,7 +4,12 @@ import {FastifyPluginAsync} from 'fastify'; const getAllByUserIdRoute: FastifyPluginAsync = async fastify => { fastify.get( '/', - {preHandler: fastify.authorize}, + { + preHandler: fastify.authorize, + schema: { + tags: ['Project'], + }, + }, async (request): Promise => { const {userId} = request; return fastify.projectRepository.findAllByUserId(userId); diff --git a/apps/api/src/routes/v1/project/getById.ts b/apps/api/src/routes/v1/project/getById.ts new file mode 100644 index 000000000..28e443a14 --- /dev/null +++ b/apps/api/src/routes/v1/project/getById.ts @@ -0,0 +1,32 @@ +import {Type} from '@sinclair/typebox'; +import {FastifyPluginAsync, FastifySchema} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types'; +import {ProjectGetByIdResponseSchema} from '../../../modules/project/schema'; + +const schema: FastifySchema = { + tags: ['Project'], + description: 'Returns a CodeImage project by id', + params: Type.Object({ + id: Type.String(), + }), + response: { + 200: ProjectGetByIdResponseSchema, + }, +}; + +export type GetProjectByIdApi = GetApiTypes; + +const getByIdRoute: FastifyPluginAsync = async fastify => { + fastify.get<{ + Params: { + id: string; + }; + }>('/:id', {preHandler: fastify.authorize, schema}, async request => { + const { + params: {id}, + } = request; + return fastify.projectRepository.findById(id); + }); +}; + +export default getByIdRoute; diff --git a/apps/api/src/routes/v1/project/update.ts b/apps/api/src/routes/v1/project/update.ts new file mode 100644 index 000000000..a32bec0af --- /dev/null +++ b/apps/api/src/routes/v1/project/update.ts @@ -0,0 +1,52 @@ +import {Static, Type} from '@sinclair/typebox'; +import {FastifyPluginAsync} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types'; +import { + ProjectUpdateRequest, + ProjectUpdateRequestSchema, + ProjectUpdateResponseSchema, +} from '../../../modules/project/schema'; + +const schema = { + tags: ['Project'], + description: 'Update an existing CodeImage project', + body: ProjectUpdateRequestSchema, + params: Type.Object({ + id: Type.String(), + }), + response: { + 200: ProjectUpdateResponseSchema, + }, +}; + +export type UpdateProjectApi = GetApiTypes; + +const updateRoute: FastifyPluginAsync = async fastify => { + fastify.put<{ + Body: Static; + Params: Static; + }>( + '/:id', + { + preHandler: fastify.authorize, + schema, + }, + async request => { + const { + userId, + body, + params: {id}, + } = request; + // TODO: move to service + const response = fastify.projectRepository.updateProject( + userId, + id, + // @ts-ignore + body as ProjectUpdateRequest, + ); + return response; + }, + ); +}; + +export default updateRoute; diff --git a/apps/api/src/routes/v1/project/updateName.ts b/apps/api/src/routes/v1/project/updateName.ts new file mode 100644 index 000000000..9425d6a57 --- /dev/null +++ b/apps/api/src/routes/v1/project/updateName.ts @@ -0,0 +1,49 @@ +import {Static, Type} from '@sinclair/typebox'; +import {FastifyPluginAsync} from 'fastify'; +import {GetApiTypes} from '../../../common/types/extract-api-types'; +import {BaseProjectResponseSchema} from '../../../modules/project/schema/project.schema'; + +const schema = { + tags: ['Project'], + description: 'Updates `name` of a CodeImage project', + body: Type.Object({ + name: Type.String(), + }), + params: Type.Object({ + id: Type.String(), + }), + response: { + 200: BaseProjectResponseSchema, + }, +}; + +export type UpdateProjectNameApi = GetApiTypes; + +const updateProjectName: FastifyPluginAsync = async fastify => { + fastify.put<{ + Params: Static; + Body: Static; + }>( + '/:id/name', + { + preHandler: fastify.authorize, + schema, + }, + async request => { + const { + userId, + body, + params: {id}, + } = request; + // TODO: move to service + const response = fastify.projectRepository.updateProjectName( + userId, + id, + body.name, + ); + return response; + }, + ); +}; + +export default updateProjectName; diff --git a/apps/api/src/routes/workspace.ts b/apps/api/src/routes/workspace.ts deleted file mode 100644 index f88c8020c..000000000 --- a/apps/api/src/routes/workspace.ts +++ /dev/null @@ -1,59 +0,0 @@ -import {Project} from '@codeimage/prisma-models'; -import {FastifyPluginAsync} from 'fastify'; -import {ProjectCreateRequest} from '../modules/workspace'; -import { - workspaceCreateRequestSchema, - workspaceCreateResponseSchema, -} from '../modules/workspace/workspace.schema'; - -const workspace: FastifyPluginAsync = async fastify => { - fastify.get( - '/workspace', - {preHandler: fastify.authorize}, - async (request): Promise => { - const {userId} = request; - return fastify.workspace.findAllByUserId(userId); - }, - ); - - fastify.post<{ - Body: ProjectCreateRequest; - }>( - '/workspace', - { - preHandler: fastify.authorize, - schema: { - tags: ['Workspace'], - body: workspaceCreateRequestSchema, - response: { - 200: workspaceCreateResponseSchema, - }, - }, - }, - async (request): Promise => { - const {userId, body} = request; - return fastify.workspace.create(body, userId); - }, - ); - - fastify.delete<{ - Params: {id: string}; - }>( - '/workspace/:id', - { - preHandler: fastify.authorize, - schema: { - tags: ['Workspace'], - }, - }, - async request => { - const { - userId, - params: {id}, - } = request; - return fastify.workspace.deleteById(id, userId); - }, - ); -}; - -export default workspace; diff --git a/apps/api/src/schemas/index.ts b/apps/api/src/schemas/index.ts new file mode 100644 index 000000000..f38f16f37 --- /dev/null +++ b/apps/api/src/schemas/index.ts @@ -0,0 +1,5 @@ +export {CreateProjectApi} from '../routes/v1/project/create'; +export {UpdateProjectNameApi} from '../routes/v1/project/updateName'; +export {DeleteProjectApi} from '../routes/v1/project/delete'; +export {UpdateProjectApi} from '../routes/v1/project/update'; +export {GetProjectByIdApi} from '../routes/v1/project/getById'; diff --git a/apps/codeimage/package.json b/apps/codeimage/package.json index 86876421b..9cefc68a6 100644 --- a/apps/codeimage/package.json +++ b/apps/codeimage/package.json @@ -35,6 +35,7 @@ "dependencies": { "@codeimage/atomic-state": "workspace:*", "@codeimage/config": "workspace:*", + "@codeimage/api": "workspace:*", "@codeimage/dom-export": "workspace:*", "@codeimage/highlight": "workspace:*", "@codeimage/locale": "workspace:*", diff --git a/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx b/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx index 606512fad..22be2e061 100644 --- a/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx +++ b/apps/codeimage/src/components/Toolbar/ToolbarSnippetName.tsx @@ -26,7 +26,14 @@ export function ToolbarSnippetName() { return; } setValue(newName); - await API.workpace.updateSnippetName($$activeWorkspace.id, newName); + await API.workpace.updateSnippetName($$activeWorkspace.id, { + params: { + id: $$activeWorkspace.id, + }, + body: { + name: newName, + }, + }); } function toggleEdit() { diff --git a/apps/codeimage/src/data-access/client.ts b/apps/codeimage/src/data-access/client.ts new file mode 100644 index 000000000..96f18ff49 --- /dev/null +++ b/apps/codeimage/src/data-access/client.ts @@ -0,0 +1,53 @@ +export interface RequestParams { + body?: unknown; + querystring?: Record; + params?: Record; + headers?: Record; +} + +export interface Schema { + request: any; + response: any; +} + +export function makeFetch( + input: RequestInfo, + requestParams: Omit & RequestParams, +): Promise { + let url = typeof input === 'string' ? input : input.url; + const headers = new Headers(); + const request: RequestInit = {...(requestParams as RequestInit)}; + + if (requestParams.querystring) { + const querystring = new URLSearchParams(); + for (const [key, value] of Object.entries(requestParams.querystring)) { + querystring.set(key, String(value)); + } + url += `?${querystring.toString()}`; + } + + if (requestParams.body) { + request.body = JSON.stringify(requestParams.body); + if (typeof requestParams.body === 'object') { + headers.set('Content-Type', 'application/json'); + } + } + + if (requestParams.headers) { + for (const [key, value] of Object.entries(requestParams.headers)) { + if (value) { + headers.append(key, value); + } + } + } + + if (requestParams.params) { + for (const [key, value] of Object.entries(requestParams.params)) { + url = url.replace(`:${key}`, value); + } + } + + request.headers = headers; + + return fetch(url, request); +} diff --git a/apps/codeimage/src/data-access/workspace.ts b/apps/codeimage/src/data-access/workspace.ts index 388f9bc4a..2f6934088 100644 --- a/apps/codeimage/src/data-access/workspace.ts +++ b/apps/codeimage/src/data-access/workspace.ts @@ -1,104 +1,88 @@ +import {ApiTypes} from '@codeimage/api/api-types'; import {supabase} from '@core/constants/supabase'; -import { - WorkspaceItem, - WorkspaceMetadata, -} from '../pages/Dashboard/dashboard.state'; +import {WorkspaceItem} from '../pages/Dashboard/dashboard.state'; +import {makeFetch} from './client'; export async function deleteProject( userId: string, - item: WorkspaceItem, -): Promise { - const headers = new Headers(); - headers.set('user-id', userId); - - return fetch(`/api/v1/project/${item.id}`, { + request: ApiTypes.DeleteProjectApi['request'], +): Promise { + return makeFetch(`/api/v1/project/:id`, { method: 'DELETE', - headers, + headers: { + 'user-id': userId, + }, + params: { + id: request.params?.id, + }, }).then(res => res.json()); } export async function updateSnippetName( - workspaceItemId: string, - newName: string, -) { - return supabase - .from('workspace_item') - .update({name: newName}) - .eq('id', workspaceItemId); + userId: string, + data: ApiTypes.UpdateProjectNameApi['request'], +): ApiTypes.UpdateProjectNameApi['response'] { + return makeFetch('/api/v1/project/:id/name', { + method: 'PUT', + params: { + id: data.params.id, + }, + body: data.body, + headers: { + 'user-id': userId, + }, + }); } export async function getWorkspaceContent(userId: string): Promise { - const headers = new Headers(); - headers.set('user-id', userId); - - return fetch('/api/v1/project', { + return makeFetch(`/api/v1/project`, { method: 'GET', - headers, + headers: { + 'user-id': userId, + }, }).then(res => res.json()); } -export async function createWorkspaceItem( - data: Pick, 'snippetId' | 'userId'>, -) { - return supabase - .from('workspace_item') - .insert(data) - .then(res => res.body?.[0]); -} - export async function updateSnippet( - snippetId: string, - dataToSave: Pick< - WorkspaceItem['snippet'], - 'terminal' | 'frame' | 'options' | 'editors' - >, + userId: string, + data: ApiTypes.UpdateProjectApi['request'], ) { - return supabase - .from('snippets') - .update(dataToSave) - .filter('id', 'eq', snippetId) - .then(res => res?.body?.[0]); + return makeFetch('/api/v1/project/:id', { + method: 'PUT', + params: { + id: data.params.id, + }, + body: data.body, + headers: { + 'user-id': userId, + }, + }); } export async function createSnippet( userId: string, - data: any, -): Promise { - const headers = new Headers(); - headers.set('user-id', userId); - headers.set('Content-Type', 'application/json'); - - return fetch('/api/v1/project', { + request: ApiTypes.CreateProjectApi['request'], +): Promise { + return makeFetch(`/api/v1/project`, { method: 'POST', - headers, - body: JSON.stringify(data), + headers: { + 'user-id': userId, + }, + body: request.body, }).then(res => res.json()); } -export async function loadSnippet(workspaceItemId: string) { - return supabase - .from('workspace_item') - .select('*, snippets(*)') - .eq('id', workspaceItemId) - .maybeSingle(); -} - -export async function createNewProject( - userId: string, - data: Pick, -) { - const workspaceItem = await supabase - .from('snippets') - .insert(data) - .then(res => res?.body?.[0]); - - if (!workspaceItem) return null; - - return supabase - .from('workspace_item') - .insert({ - snippetId: workspaceItem.id, - userId, - }) - .then(res => res?.body?.[0]); +export async function loadSnippet( + userId: string | null | undefined, + projectId: string, +): Promise { + return makeFetch(`/api/v1/project/:id`, { + method: 'GET', + params: { + id: projectId, + }, + headers: { + 'user-id': userId, + }, + }).then(res => res.json()); } diff --git a/apps/codeimage/src/pages/Dashboard/dashboard.state.ts b/apps/codeimage/src/pages/Dashboard/dashboard.state.ts index 59aa74be8..7ab53a74c 100644 --- a/apps/codeimage/src/pages/Dashboard/dashboard.state.ts +++ b/apps/codeimage/src/pages/Dashboard/dashboard.state.ts @@ -1,15 +1,6 @@ -import { - SnippetEditorOptions, - SnippetEditorTab, - SnippetFrame, - SnippetTerminal, - Project as SnippetProject, -} from '@codeimage/prisma-models'; +import {ApiTypes} from '@codeimage/api/api-types'; import {getAuthState} from '@codeimage/store/auth/auth'; -import { - getInitialEditorState, - getInitialEditorUiOptions, -} from '@codeimage/store/editor/editor'; +import {getInitialEditorUiOptions} from '@codeimage/store/editor/editor'; import {getInitialFrameState} from '@codeimage/store/editor/frame'; import { PersistedEditorState, @@ -17,7 +8,7 @@ import { } from '@codeimage/store/editor/model'; import {getInitialTerminalState} from '@codeimage/store/editor/terminal'; import {PersistedFrameState} from '@codeimage/store/frame/model'; -import {createUniqueId} from '@codeimage/store/plugins/unique-id'; +import {getThemeStore} from '@codeimage/store/theme/theme.store'; import {appEnvironment} from '@core/configuration'; import {createContextProvider} from '@solid-primitives/context'; import {createResource, createSignal} from 'solid-js'; @@ -62,30 +53,36 @@ function makeDashboardState() { if (!userId) { return; } - const editor = {...getInitialEditorState(), id: createUniqueId()}; - type IdAndSnippetId = 'id' | 'projectId'; - type WorkspaceRequest = { - name: SnippetProject['name']; - editorOptions: Omit; - terminal: Omit; - frame: Omit; - editors: Omit[]; - }; + const theme = await getThemeStore().getThemeDef('vsCodeDarkTheme')?.load(); - const data: WorkspaceRequest = { - name: 'Untitled', - editorOptions: getInitialEditorUiOptions(), - terminal: getInitialTerminalState(), - // @ts-expect-error TODO: fix - frame: getInitialFrameState(), - editors: [ - { - code: appEnvironment.defaultState.editor.code, - languageId: appEnvironment.defaultState.editor.languageId, - tabName: 'index.tsx', + const frame = getInitialFrameState(); + + const data: ApiTypes.CreateProjectApi['request'] = { + body: { + name: 'Untitled', + editorOptions: getInitialEditorUiOptions(), + terminal: { + ...getInitialTerminalState(), + background: theme?.properties.terminal.main ?? null, + textColor: theme?.properties.terminal.text ?? null, + }, + frame: { + visible: frame.visible ?? false, + padding: frame.padding ?? 0, + radius: frame.radius ?? 0, + background: + theme?.properties.previewBackground ?? frame.background ?? '#000', + opacity: frame.opacity ?? 1, }, - ], + editors: [ + { + code: `from server: ${appEnvironment.defaultState.editor.code}`, + languageId: appEnvironment.defaultState.editor.languageId, + tabName: 'index.tsx', + }, + ], + }, }; return API.workpace.createSnippet(userId, data); @@ -93,8 +90,13 @@ function makeDashboardState() { async function deleteProject(item: WorkspaceItem) { const userId = getAuthState().user()?.user?.id; + if (!userId) return; mutate(items => items.filter(i => i.id !== item.id)); - await API.workpace.deleteProject(userId!, item); + await API.workpace.deleteProject(userId, { + params: { + id: item.id, + }, + }); } return { diff --git a/apps/codeimage/src/state/editor/createEditorInit.ts b/apps/codeimage/src/state/editor/createEditorInit.ts index ba20c3459..ae6bf57a6 100644 --- a/apps/codeimage/src/state/editor/createEditorInit.ts +++ b/apps/codeimage/src/state/editor/createEditorInit.ts @@ -1,3 +1,4 @@ +import {ApiTypes} from '@codeimage/api/api-types'; import {getAuthState} from '@codeimage/store/auth/auth'; import {getRootEditorStore} from '@codeimage/store/editor'; import {getFrameState} from '@codeimage/store/editor/frame'; @@ -19,13 +20,6 @@ import {unwrap} from 'solid-js/store'; import {API} from '../../data-access/api'; import {useIdb} from '../../hooks/use-indexed-db'; import {WorkspaceItem} from '../../pages/Dashboard/dashboard.state'; -import { - SnippetEditorOptions, - SnippetTerminal, - SnippetFrame, - SnippetEditorTab, - WorkspaceItem as PrismaWorkspaceItem, -} from '@codeimage/prisma-models'; function createEditorSyncAdapter() { const [remoteSync, setRemoteSync] = createSignal(false); @@ -44,12 +38,16 @@ function createEditorSyncAdapter() { const snippetId = createMemo(() => data()?.snippetId); const [loadedSnippet] = createResource(snippetId, async snippetId => { + const userId = authState.user()?.user?.id; if (snippetId) { - const storedWorkspaceData = await API.workpace.loadSnippet(snippetId); - if (storedWorkspaceData.data) { - updateStateFromRemote(storedWorkspaceData.data); + const storedWorkspaceData = await API.workpace.loadSnippet( + userId, + snippetId, + ); + if (storedWorkspaceData) { + updateStateFromRemote(storedWorkspaceData); } - return storedWorkspaceData?.data; + return storedWorkspaceData; } }); @@ -90,14 +88,14 @@ function createEditorSyncAdapter() { }), ); - function updateStateFromRemote(data: WorkspaceItem) { + function updateStateFromRemote(data: ApiTypes.GetProjectByIdApi['response']) { setActiveWorkspace(data); editorStore.actions.setFromWorkspace(data); terminalStore.setState(state => ({ ...state, - ...data.snippet.terminal, + ...data.terminal, })); - frameStore.setStore(state => ({...state, ...data.snippet.frame})); + frameStore.setStore(state => ({...state, ...data.frame})); } function initRemoteDbSync() { @@ -112,21 +110,21 @@ function createEditorSyncAdapter() { tap(() => setRemoteSync(false)), ) .subscribe(async ([frame, terminal, {editors, options}]) => { - const dataToSave = { + const dataToSave: ApiTypes.CreateProjectApi['request']['body'] = { frame, terminal, editors, - options, + editorOptions: options, }; const workspace = untrack(activeWorkspace); if (!workspace) return; if (activeWorkspace()) { - const snippet = await API.workpace.updateSnippet( - workspace.snippetId, - dataToSave, - ); + const snippet = await API.workpace.updateSnippet(workspace.userId, { + body: dataToSave, + params: {id: workspace.id}, + }); if (!snippet) return; // setActiveWorkspace({ // ...workspace, @@ -135,10 +133,9 @@ function createEditorSyncAdapter() { } else { const userId = getAuthState().user()?.user?.id; if (!userId) return; - const workspaceItem = await API.workpace.createSnippet( - userId, - dataToSave, - ); + const workspaceItem = await API.workpace.createSnippet(userId, { + body: dataToSave, + }); setActiveWorkspace(workspaceItem ?? undefined); } }); diff --git a/apps/codeimage/src/state/editor/editor.ts b/apps/codeimage/src/state/editor/editor.ts index 6eddf8f1c..caa4db45b 100644 --- a/apps/codeimage/src/state/editor/editor.ts +++ b/apps/codeimage/src/state/editor/editor.ts @@ -1,3 +1,4 @@ +import {ApiTypes} from '@codeimage/api/api-types'; import { createDerivedObservable, createDerivedSetter, @@ -9,7 +10,6 @@ import {appEnvironment} from '@core/configuration'; import {SUPPORTED_FONTS} from '@core/configuration/font'; import {filter} from '@solid-primitives/immutable'; import {createMemo, createSelector} from 'solid-js'; -import {WorkspaceItem} from '../../pages/Dashboard/dashboard.state'; import {EditorState, EditorUIOptions, PersistedEditorState} from './model'; const defaultId = createUniqueId(); @@ -54,7 +54,7 @@ export function createEditorsStore() { return { languageId: editor.languageId, code: editor.code, - tab: editor.tab, + tabName: editor.tab.tabName, id: editor.id, }; }), @@ -122,15 +122,22 @@ export function createEditorsStore() { () => filter(SUPPORTED_FONTS, font => font.id === state.options.fontId)[0], ); - const setFromWorkspace = (item: WorkspaceItem) => { + const setFromWorkspace = (item: ApiTypes.GetProjectByIdApi['response']) => { setEditors( - item.snippet.editors.map(editor => ({ - ...editor, - code: editor.code, - })), + item.editorTabs.map( + editor => + ({ + tab: { + tabName: editor.tabName, + }, + languageId: editor.languageId, + id: editor.id, + code: editor.code, + } as EditorState), + ), ); - setState('activeEditorId', item.snippet.editors[0].id); - setState('options', item.snippet.options); + setState('activeEditorId', item.editorTabs[0].id); + setState('options', item.editorOptions); }; return { @@ -152,13 +159,24 @@ export function createEditorsStore() { const editors = (state.editors ?? []) .slice(0, MAX_TABS) .map(editor => ({ - ...editor, + tabName: editor.tab.tabName, + languageId: editor.languageId, + id: editor.id, code: editor.code, })); setState(state => ({ options: {...state.options, ...state.options}, activeEditorId: editors[0].id, - editors: [...editors], + editors: editors.map(editor => { + return { + code: editor.code, + languageId: editor.languageId, + tab: { + tabName: editor.tabName, + }, + id: editor.id, + }; + }), })); }, setActiveEditorId: (id: string) => { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e7cb66284..572d69d8a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -55,23 +55,25 @@ importers: apps/api: specifiers: '@codeimage/prisma-models': workspace:* - '@fastify/autoload': ^5.0.0 - '@fastify/env': 4.0.0 - '@fastify/sensible': ^4.1.0 - '@fastify/swagger': 7.4.1 + '@fastify/autoload': ^5.1.0 + '@fastify/env': ^4.0.0 + '@fastify/sensible': ^5.1.0 + '@fastify/swagger': ^7.4.1 + '@fastify/type-provider-typebox': ^2.2.0 '@prisma/client': ^4.1.1 + '@sinclair/typebox': ^0.24.26 '@types/node': ^18.0.0 '@types/tap': ^15.0.5 concurrently: ^7.0.0 - dotenv-cli: 6.0.0 - fastify: ^4.0.0 + dotenv-cli: ^6.0.0 + fastify: ^4.3.0 fastify-cli: ^4.4.0 - fastify-plugin: ^3.0.0 + fastify-plugin: ^4.0.0 fastify-tsconfig: ^1.0.1 - fluent-json-schema: 3.1.0 - graphql: 16.5.0 - mercurius: 10.1.0 - mercurius-codegen: 4.0.1 + fluent-json-schema: ^3.1.0 + graphql: ^16.5.0 + mercurius: ^10.1.0 + mercurius-codegen: ^4.0.1 prisma: 4.1.1 prisma-json-schema-generator: 3.0.1 tap: ^16.1.0 @@ -81,13 +83,15 @@ importers: '@codeimage/prisma-models': link:../../packages/prisma-models '@fastify/autoload': 5.1.0 '@fastify/env': 4.0.0 - '@fastify/sensible': 4.1.0 + '@fastify/sensible': 5.1.0 '@fastify/swagger': 7.4.1 + '@fastify/type-provider-typebox': 2.2.0_a75yjc2tfv5mts5ixrv6fsbfre '@prisma/client': 4.1.1_prisma@4.1.1 + '@sinclair/typebox': 0.24.26 dotenv-cli: 6.0.0 fastify: 4.3.0 fastify-cli: 4.4.0 - fastify-plugin: 3.0.1 + fastify-plugin: 4.0.0 fluent-json-schema: 3.1.0 graphql: 16.5.0 mercurius: 10.1.0 @@ -105,6 +109,7 @@ importers: apps/codeimage: specifiers: + '@codeimage/api': workspace:* '@codeimage/atomic-state': workspace:* '@codeimage/config': workspace:* '@codeimage/dom-export': workspace:* @@ -185,6 +190,7 @@ importers: workbox-strategies: ^6.5.3 workbox-window: ^6.5.4 dependencies: + '@codeimage/api': link:../api '@codeimage/atomic-state': link:../../packages/atomic-state '@codeimage/config': link:../../packages/config '@codeimage/dom-export': link:../../packages/dom-export @@ -3035,9 +3041,8 @@ packages: fast-json-stringify: 5.1.0 dev: false - /@fastify/sensible/4.1.0: - resolution: {integrity: sha512-8TBlmCK055y6WO9jZlndmceB9x8NyNcLEbnJtdu44zelfmY1ebBMSB7MOqyMteyDvpSMq3CVaPknBu35d9FRlA==} - engines: {node: '>=14.0.0'} + /@fastify/sensible/5.1.0: + resolution: {integrity: sha512-ZLuYDpBZecCsr5ZlTKtcyA1dg9ExiEHdTxarYMaBnCJzPBUvTNoDMypMCC6XcC1VS7idMmKjlGmHfmMAaz761Q==} dependencies: fast-deep-equal: 3.1.3 fastify-plugin: 3.0.1 @@ -3075,6 +3080,16 @@ packages: - supports-color dev: false + /@fastify/type-provider-typebox/2.2.0_a75yjc2tfv5mts5ixrv6fsbfre: + resolution: {integrity: sha512-BpqVX1wDjzxydWdDxCsnGeFU9NDnkytoN674JSC8I5RoihTNRWHPuNTDdoVNZXjB9OjyKh9IQ0Ac3tgJZn5sWQ==} + peerDependencies: + '@sinclair/typebox': ^0.24.1 + fastify: ^4.0.0 + dependencies: + '@sinclair/typebox': 0.24.26 + fastify: 4.3.0 + dev: false + /@fastify/websocket/6.0.1: resolution: {integrity: sha512-RdrPMpD/gjm7ocqFZN2TVHRAjDNG483dvH7cbMXJPTvIei2xz/sqa5TwtqGPrupU91GZjlUYQqUjQb/cQHA7kw==} dependencies: @@ -4024,6 +4039,10 @@ packages: string-argv: 0.3.1 dev: true + /@sinclair/typebox/0.24.26: + resolution: {integrity: sha512-1ZVIyyS1NXDRVT8GjWD5jULjhDyM3IsIHef2VGUMdnWOlX2tkPjyEX/7K0TGSH2S8EaPhp1ylFdjSjUGQ+gecg==} + dev: false + /@solid-aria/button/0.1.3_solid-js@1.4.8: resolution: {integrity: sha512-gN7/d5YkHAbQPhzBVbNNp9grf9w+mxFRulbmeXjp61hFNLOJRDcdMPCri5MmfUJ8D2BwefcyHvCGTCc62Ua23A==} peerDependencies: