From 6eb6ea55acbe760ecd9f67275f3b1768caa379ac Mon Sep 17 00:00:00 2001 From: "stefan.meyer" Date: Fri, 25 Oct 2024 09:05:07 +0000 Subject: [PATCH] added domain tab in app --- .../20241025084526_migration/migration.sql | 38 ++++++ .../20241025085330_migration/migration.sql | 8 ++ prisma/schema.prisma | 8 +- .../project/app/[tabName]/domains/actions.ts | 29 +++-- .../domains/domain-edit.-overlay.tsx | 117 ++++++++++++++++++ .../project/app/[tabName]/domains/domains.tsx | 23 +++- .../app/[tabName]/environment/env-edit.tsx | 1 - src/lib/zod.utils.ts | 60 ++++++--- src/model/domain-edit.model.ts | 10 ++ src/model/generated-zod/account.ts | 2 +- src/model/generated-zod/app.ts | 2 +- src/model/generated-zod/appdomain.ts | 3 +- src/model/generated-zod/appvolume.ts | 3 +- src/model/generated-zod/authenticator.ts | 2 +- src/model/generated-zod/project.ts | 2 +- src/model/generated-zod/session.ts | 2 +- src/model/generated-zod/user.ts | 2 +- src/model/generated-zod/verificationtoken.ts | 2 +- src/server/services/app.service.ts | 106 +++++++++++++--- src/server/utils/cache-tag-generator.utils.ts | 4 + 20 files changed, 358 insertions(+), 66 deletions(-) create mode 100644 prisma/migrations/20241025084526_migration/migration.sql create mode 100644 prisma/migrations/20241025085330_migration/migration.sql create mode 100644 src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx create mode 100644 src/model/domain-edit.model.ts diff --git a/prisma/migrations/20241025084526_migration/migration.sql b/prisma/migrations/20241025084526_migration/migration.sql new file mode 100644 index 0000000..dad6f4f --- /dev/null +++ b/prisma/migrations/20241025084526_migration/migration.sql @@ -0,0 +1,38 @@ +/* + Warnings: + + - The primary key for the `AppDomain` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The primary key for the `AppVolume` table will be changed. If it partially fails, the table could be left without primary key constraint. + - The required column `id` was added to the `AppDomain` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + - The required column `id` was added to the `AppVolume` table with a prisma-level default value. This is not possible if the table is not empty. Please add this column as optional, then populate it before making it required. + +*/ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_AppDomain" ( + "id" TEXT NOT NULL PRIMARY KEY, + "hostname" TEXT NOT NULL, + "port" INTEGER NOT NULL, + "useSsl" BOOLEAN NOT NULL DEFAULT true, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppDomain_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_AppDomain" ("appId", "createdAt", "hostname", "port", "updatedAt", "useSsl") SELECT "appId", "createdAt", "hostname", "port", "updatedAt", "useSsl" FROM "AppDomain"; +DROP TABLE "AppDomain"; +ALTER TABLE "new_AppDomain" RENAME TO "AppDomain"; +CREATE TABLE "new_AppVolume" ( + "id" TEXT NOT NULL PRIMARY KEY, + "containerMountPath" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); +INSERT INTO "new_AppVolume" ("appId", "containerMountPath", "createdAt", "updatedAt") SELECT "appId", "containerMountPath", "createdAt", "updatedAt" FROM "AppVolume"; +DROP TABLE "AppVolume"; +ALTER TABLE "new_AppVolume" RENAME TO "AppVolume"; +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/migrations/20241025085330_migration/migration.sql b/prisma/migrations/20241025085330_migration/migration.sql new file mode 100644 index 0000000..ffe6f0d --- /dev/null +++ b/prisma/migrations/20241025085330_migration/migration.sql @@ -0,0 +1,8 @@ +/* + Warnings: + + - A unique constraint covering the columns `[hostname]` on the table `AppDomain` will be added. If there are existing duplicate values, this will fail. + +*/ +-- CreateIndex +CREATE UNIQUE INDEX "AppDomain_hostname_key" ON "AppDomain"("hostname"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ef8b24e..7d245c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -152,7 +152,8 @@ model App { } model AppDomain { - hostname String + id String @id @default(uuid()) + hostname String @unique port Int useSsl Boolean @default(true) appId String @@ -160,17 +161,14 @@ model AppDomain { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - @@id([hostname, appId]) } model AppVolume { + id String @id @default(uuid()) containerMountPath String appId String app App @relation(fields: [appId], references: [id]) createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - - @@id([containerMountPath, appId]) } diff --git a/src/app/project/app/[tabName]/domains/actions.ts b/src/app/project/app/[tabName]/domains/actions.ts index 0fd87a1..86af99e 100644 --- a/src/app/project/app/[tabName]/domains/actions.ts +++ b/src/app/project/app/[tabName]/domains/actions.ts @@ -1,17 +1,28 @@ 'use server' -import { AppEnvVariablesModel, appEnvVariablesZodModel } from "@/model/env-edit.model"; +import { appDomainEditZodModel } from "@/model/domain-edit.model"; +import { SuccessActionResult } from "@/model/server-action-error-return.model"; import appService from "@/server/services/app.service"; -import { getAuthUserSession, saveFormAction } from "@/server/utils/action-wrapper.utils"; +import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { z } from "zod"; +const actionAppDomainEditZodModel = appDomainEditZodModel.merge(z.object({ + appId: z.string(), + id: z.string().nullish() +})); -export const saveEnvVariables = async (prevState: any, inputData: AppEnvVariablesModel, appId: string) => - saveFormAction(inputData, appEnvVariablesZodModel, async (validatedData) => { +export const saveDomain = async (prevState: any, inputData: z.infer) => + saveFormAction(inputData, actionAppDomainEditZodModel, async (validatedData) => { await getAuthUserSession(); - const existingApp = await appService.getById(appId); - await appService.save({ - ...existingApp, + await appService.saveDomain({ ...validatedData, - id: appId, + id: validatedData.id ?? undefined + }); + }); + + export const deleteDomain = async (domainId: string) => + simpleAction(async () => { + await getAuthUserSession(); + await appService.deleteDomainById(domainId); + return new SuccessActionResult(undefined, 'Successfully deleted domain'); }); - }); \ No newline at end of file diff --git a/src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx b/src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx new file mode 100644 index 0000000..5f16dfc --- /dev/null +++ b/src/app/project/app/[tabName]/domains/domain-edit.-overlay.tsx @@ -0,0 +1,117 @@ +'use client' + +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { useFormState } from 'react-dom' +import { useEffect, useState } from "react"; +import { FormUtils } from "@/lib/form.utilts"; +import { SubmitButton } from "@/components/custom/submit-button"; +import { AppDomain } from "@prisma/client" +import { AppDomainEditModel, appDomainEditZodModel } from "@/model/domain-edit.model" +import { ServerActionResult } from "@/model/server-action-error-return.model" +import { saveDomain } from "./actions" +import { toast } from "sonner" +import CheckboxFormField from "@/components/custom/checkbox-form-field" + + + +export default function DialogEditDialog({ children, domain, appId }: { children: React.ReactNode; domain?: AppDomain; appId: string; }) { + + const [isOpen, setIsOpen] = useState(false); + + + const form = useForm({ + resolver: zodResolver(appDomainEditZodModel), + defaultValues: { + ...domain, + useSsl: domain?.useSsl === false ? false : true + } + }); + + const [state, formAction] = useFormState((state: ServerActionResult, payload: AppDomainEditModel) => + saveDomain(state, { + ...payload, + appId, + id: domain?.id + }), FormUtils.getInitialFormState()); + + useEffect(() => { + if (state.status === 'success') { + form.reset(); + toast.success('Domain saved successfully'); + setIsOpen(false); + } + FormUtils.mapValidationErrorsToForm(state, form); + }, [state]); + + + return ( + <> +
setIsOpen(true)}> + {children} +
+ setIsOpen(false)}> + + + Edit Domain + + Configure your custom domain for this application. Note that the domain must be pointing to the server IP address. + + +
+ form.handleSubmit((data) => { + return formAction(data); + })()}> +
+ ( + + Hostname + + + + + + )} + /> + + ( + + App Port + + + + + + )} + /> + + +

{state.message}

+ Save +
+
+ +
+
+ + ) + + + +} \ No newline at end of file diff --git a/src/app/project/app/[tabName]/domains/domains.tsx b/src/app/project/app/[tabName]/domains/domains.tsx index 85d8bdd..7f3c351 100644 --- a/src/app/project/app/[tabName]/domains/domains.tsx +++ b/src/app/project/app/[tabName]/domains/domains.tsx @@ -7,7 +7,6 @@ import { FormUtils } from "@/lib/form.utilts"; import { AppSourceInfoInputModel, appSourceInfoInputZodModel } from "@/model/app-source-info.model"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; -import { saveEnvVariables } from "./actions"; import { useFormState } from "react-dom"; import { ServerActionResult } from "@/model/server-action-error-return.model"; import { Input } from "@/components/ui/input"; @@ -22,7 +21,10 @@ import { Textarea } from "@/components/ui/textarea"; import { AppExtendedModel } from "@/model/app-extended.model"; import { Table, TableBody, TableCaption, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; -import { CheckIcon, CrossIcon } from "lucide-react"; +import { CheckIcon, CrossIcon, DeleteIcon, EditIcon, TrashIcon, XIcon } from "lucide-react"; +import DialogEditDialog from "./domain-edit.-overlay"; +import { Toast } from "@/lib/toast.utils"; +import { deleteDomain } from "./actions"; export default function DomainsList({ app }: { @@ -42,7 +44,7 @@ export default function DomainsList({ app }: { Name Port SSL - Action + Action @@ -50,15 +52,24 @@ export default function DomainsList({ app }: { {domain.hostname} {domain.port} - {domain.useSsl ? : } - + {domain.useSsl ? : } + + + + + + ))} - + + + diff --git a/src/app/project/app/[tabName]/environment/env-edit.tsx b/src/app/project/app/[tabName]/environment/env-edit.tsx index 367b9c7..07f6c3e 100644 --- a/src/app/project/app/[tabName]/environment/env-edit.tsx +++ b/src/app/project/app/[tabName]/environment/env-edit.tsx @@ -38,7 +38,6 @@ export default function EnvEdit({ app }: { FormUtils.mapValidationErrorsToForm(state, form); }, [state]); - const sourceTypeField = form.watch(); return <> diff --git a/src/lib/zod.utils.ts b/src/lib/zod.utils.ts index 3b0f653..20773b8 100644 --- a/src/lib/zod.utils.ts +++ b/src/lib/zod.utils.ts @@ -1,4 +1,17 @@ -import { z } from "zod"; +import { z, ZodObject, ZodRawShape } from "zod"; + +export class ZodUtils { + static getFieldNamesAndTypes(schema: ZodObject): Map { + const shape = schema.shape; + const fieldMap = new Map(); + + for (const [key, value] of Object.entries(shape)) { + fieldMap.set(key, value._def.typeName); + } + + return fieldMap; + } +} export const stringToNumber = z.union([z.string(), z.number()]) .transform((val) => { @@ -58,22 +71,37 @@ export const stringToDate = z.union([z.string(), z.date()]) message: 'Der Eingegebene Wert muss ein Datum sein.', }); +export const stringToOptionalBoolean = z.preprocess((val) => { + if (val === null || val === undefined) { + return null; + } + if (typeof val === 'string') { + if (val === 'true') { + return true; + } + if (val === 'false') { + return false; + } + return null; + } + return val; +}, z.boolean().nullish()); -/*z.union([z.string(), z.number(), z.null(), z.undefined()]) - .transform((val) => { - if (val === null || val === undefined) { - return null; +export const stringToBoolean = z.preprocess((val) => { + if (val === null || val === undefined) { + return false; + } + if (typeof val === 'string') { + if (val === 'true') { + return true; } - if (typeof val === 'string') { - const parsed = parseFloat(val); - if (isNaN(parsed)) { - return null; - } - return parsed; + if (val === 'false') { + return false; } + return false; + } + if (typeof val === 'boolean') { return val; - }) - .refine((val) => val === null || typeof val === 'number', { - message: 'Der Eingegebene Wert muss eine Zahl oder leer sein.', - }); -*/ \ No newline at end of file + } + return false; // default false +}, z.boolean()); diff --git a/src/model/domain-edit.model.ts b/src/model/domain-edit.model.ts new file mode 100644 index 0000000..d68712e --- /dev/null +++ b/src/model/domain-edit.model.ts @@ -0,0 +1,10 @@ +import { stringToBoolean, stringToNumber } from "@/lib/zod.utils"; +import { z } from "zod"; + +export const appDomainEditZodModel = z.object({ + hostname: z.string().trim().min(1), + useSsl: stringToBoolean, + port: stringToNumber, +}) + +export type AppDomainEditModel = z.infer; \ No newline at end of file diff --git a/src/model/generated-zod/account.ts b/src/model/generated-zod/account.ts index 0fb0328..6736da2 100644 --- a/src/model/generated-zod/account.ts +++ b/src/model/generated-zod/account.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteUser, RelatedUserModel } from "./index" export const AccountModel = z.object({ diff --git a/src/model/generated-zod/app.ts b/src/model/generated-zod/app.ts index 11797c1..1de015a 100644 --- a/src/model/generated-zod/app.ts +++ b/src/model/generated-zod/app.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteProject, RelatedProjectModel, CompleteAppDomain, RelatedAppDomainModel, CompleteAppVolume, RelatedAppVolumeModel } from "./index" export const AppModel = z.object({ diff --git a/src/model/generated-zod/appdomain.ts b/src/model/generated-zod/appdomain.ts index bf55449..06cfddb 100644 --- a/src/model/generated-zod/appdomain.ts +++ b/src/model/generated-zod/appdomain.ts @@ -1,8 +1,9 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteApp, RelatedAppModel } from "./index" export const AppDomainModel = z.object({ + id: z.string(), hostname: z.string(), port: z.number().int(), useSsl: z.boolean(), diff --git a/src/model/generated-zod/appvolume.ts b/src/model/generated-zod/appvolume.ts index beabb2e..7a3fecc 100644 --- a/src/model/generated-zod/appvolume.ts +++ b/src/model/generated-zod/appvolume.ts @@ -1,8 +1,9 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteApp, RelatedAppModel } from "./index" export const AppVolumeModel = z.object({ + id: z.string(), containerMountPath: z.string(), appId: z.string(), createdAt: z.date(), diff --git a/src/model/generated-zod/authenticator.ts b/src/model/generated-zod/authenticator.ts index c65b1a2..6aafa7e 100644 --- a/src/model/generated-zod/authenticator.ts +++ b/src/model/generated-zod/authenticator.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteUser, RelatedUserModel } from "./index" export const AuthenticatorModel = z.object({ diff --git a/src/model/generated-zod/project.ts b/src/model/generated-zod/project.ts index e100fe5..7413f0e 100644 --- a/src/model/generated-zod/project.ts +++ b/src/model/generated-zod/project.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteApp, RelatedAppModel } from "./index" export const ProjectModel = z.object({ diff --git a/src/model/generated-zod/session.ts b/src/model/generated-zod/session.ts index 45c0e73..319a38f 100644 --- a/src/model/generated-zod/session.ts +++ b/src/model/generated-zod/session.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteUser, RelatedUserModel } from "./index" export const SessionModel = z.object({ diff --git a/src/model/generated-zod/user.ts b/src/model/generated-zod/user.ts index d187007..4a9c1f6 100644 --- a/src/model/generated-zod/user.ts +++ b/src/model/generated-zod/user.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + import { CompleteAccount, RelatedAccountModel, CompleteSession, RelatedSessionModel, CompleteAuthenticator, RelatedAuthenticatorModel } from "./index" export const UserModel = z.object({ diff --git a/src/model/generated-zod/verificationtoken.ts b/src/model/generated-zod/verificationtoken.ts index 1df1715..c75a335 100644 --- a/src/model/generated-zod/verificationtoken.ts +++ b/src/model/generated-zod/verificationtoken.ts @@ -1,5 +1,5 @@ import * as z from "zod" -import * as imports from "../../../prisma/null" + export const VerificationTokenModel = z.object({ identifier: z.string(), diff --git a/src/server/services/app.service.ts b/src/server/services/app.service.ts index d51f516..155d65c 100644 --- a/src/server/services/app.service.ts +++ b/src/server/services/app.service.ts @@ -1,9 +1,10 @@ import { revalidateTag, unstable_cache } from "next/cache"; import dataAccess from "../adapter/db.client"; import { Tags } from "../utils/cache-tag-generator.utils"; -import { App, Prisma, Project } from "@prisma/client"; +import { App, AppDomain, Prisma } from "@prisma/client"; import { DefaultArgs } from "@prisma/client/runtime/library"; import { AppExtendedModel } from "@/model/app-extended.model"; +import { ServiceException } from "@/model/service.exception.model"; class AppService { @@ -18,6 +19,7 @@ class AppService { } }); revalidateTag(Tags.apps(existingItem.projectId)); + revalidateTag(Tags.app(existingItem.id)); } async getAllAppsByProjectID(projectId: string) { @@ -31,8 +33,8 @@ class AppService { })(projectId as string); } - async getExtendedById(id: string): Promise { - return dataAccess.client.app.findFirstOrThrow({ + async getExtendedById(appId: string): Promise { + return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({ where: { id }, include: { @@ -40,35 +42,99 @@ class AppService { appDomains: true, appVolumes: true, } - }); + }), + [Tags.app(appId)], { + tags: [Tags.app(appId)] + })(appId); } - async getById(id: string) { - return dataAccess.client.app.findFirstOrThrow({ + async getById(appId: string) { + return await unstable_cache(async (id: string) => await dataAccess.client.app.findFirstOrThrow({ where: { id } - }); + }), + [Tags.app(appId)], { + tags: [Tags.app(appId)] + })(appId); } async save(item: Prisma.AppUncheckedCreateInput | Prisma.AppUncheckedUpdateInput) { let savedItem: Prisma.Prisma__AppClient; - if (item.id) { - savedItem = dataAccess.client.app.update({ - where: { - id: item.id as string - }, - data: item - }); - } else { - savedItem = dataAccess.client.app.create({ - data: item as Prisma.AppUncheckedCreateInput - }); + try { + if (item.id) { + savedItem = dataAccess.client.app.update({ + where: { + id: item.id as string + }, + data: item + }); + } else { + savedItem = dataAccess.client.app.create({ + data: item as Prisma.AppUncheckedCreateInput + }); + } + } finally { + revalidateTag(Tags.apps(item.projectId as string)); + revalidateTag(Tags.app(item.id as string)); } - - revalidateTag(Tags.apps(item.projectId as string)); return savedItem; } + + async saveDomain(domainToBeSaved: Prisma.AppDomainUncheckedCreateInput | Prisma.AppDomainUncheckedUpdateInput) { + let savedItem: AppDomain; + const existingApp = await this.getExtendedById(domainToBeSaved.appId as string); + const existingDomainWithSameHostname = await dataAccess.client.appDomain.findFirst({ + where: { + hostname: domainToBeSaved.hostname as string, + } + }); + if (domainToBeSaved.id && domainToBeSaved.id !== existingDomainWithSameHostname?.id) { + throw new ServiceException("Hostname is already in use by this or another app."); + } + try { + if (domainToBeSaved.id) { + savedItem = await dataAccess.client.appDomain.update({ + where: { + id: domainToBeSaved.id as string + }, + data: domainToBeSaved + }); + } else { + savedItem = await dataAccess.client.appDomain.create({ + data: domainToBeSaved as Prisma.AppDomainUncheckedCreateInput + }); + } + + } finally { + revalidateTag(Tags.apps(existingApp.projectId as string)); + revalidateTag(Tags.app(existingApp.id as string)); + } + return savedItem; + } + + async deleteDomainById(id: string) { + const existingDomain = await dataAccess.client.appDomain.findFirst({ + where: { + id + }, include: { + app: true + } + }); + if (!existingDomain) { + return; + } + try { + await dataAccess.client.appDomain.delete({ + where: { + id + } + }); + } finally { + revalidateTag(Tags.app(existingDomain.appId)); + revalidateTag(Tags.apps(existingDomain.app.projectId)); + } + } } const appService = new AppService(); diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index bc2a588..7efaaf4 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -11,4 +11,8 @@ export class Tags { static apps(projectId: string) { return `apps-${projectId}`; } + + static app(appId: string) { + return `app-${appId}`; + } } \ No newline at end of file