diff --git a/prisma/migrations/20241021100307_migration/migration.sql b/prisma/migrations/20241021100307_migration/migration.sql new file mode 100644 index 0000000..041179f --- /dev/null +++ b/prisma/migrations/20241021100307_migration/migration.sql @@ -0,0 +1,52 @@ +-- CreateTable +CREATE TABLE "Project" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL +); + +-- CreateTable +CREATE TABLE "App" ( + "id" TEXT NOT NULL PRIMARY KEY, + "name" TEXT NOT NULL, + "projectId" TEXT NOT NULL, + "gitUrl" TEXT NOT NULL, + "gitBranch" TEXT NOT NULL, + "gitUsername" TEXT, + "gitToken" TEXT, + "dockerfilePath" TEXT NOT NULL DEFAULT './Dockerfile', + "replicas" INTEGER NOT NULL DEFAULT 1, + "envVars" TEXT NOT NULL, + "memoryReservation" INTEGER, + "memoryLimit" INTEGER, + "cpuReservation" INTEGER, + "cpuLimit" INTEGER, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + CONSTRAINT "App_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AppDomain" ( + "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, + + PRIMARY KEY ("hostname", "appId"), + CONSTRAINT "AppDomain_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "AppVolume" ( + "containerMountPath" TEXT NOT NULL, + "appId" TEXT NOT NULL, + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" DATETIME NOT NULL, + + PRIMARY KEY ("containerMountPath", "appId"), + CONSTRAINT "AppVolume_appId_fkey" FOREIGN KEY ("appId") REFERENCES "App" ("id") ON DELETE RESTRICT ON UPDATE CASCADE +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f69f6e9..67fc213 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -111,3 +111,63 @@ model Authenticator { } // *** FROM HERE CUSTOM CLASSES + +model Project { + id String @id @default(uuid()) + name String + apps App[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model App { + id String @id @default(uuid()) + name String + projectId String + project Project @relation(fields: [projectId], references: [id]) + + gitUrl String + gitBranch String + gitUsername String? + gitToken String? + dockerfilePath String @default("./Dockerfile") + + replicas Int @default(1) + envVars String + + memoryReservation Int? + memoryLimit Int? + cpuReservation Int? + cpuLimit Int? + + appDomains AppDomain[] + appVolumes AppVolume[] + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt +} + +model AppDomain { + hostname String + port Int + useSsl Boolean @default(true) + appId String + app App @relation(fields: [appId], references: [id]) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@id([hostname, appId]) +} + +model AppVolume { + 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/page.tsx b/src/app/page.tsx index d863362..d3a44f8 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,9 +1,6 @@ import { Button } from "@/components/ui/button"; +import ProjectPage from "./projects/page"; export default function Home() { - return ( -
- -
- ); + return ; } diff --git a/src/app/projects/actions.ts b/src/app/projects/actions.ts new file mode 100644 index 0000000..3761550 --- /dev/null +++ b/src/app/projects/actions.ts @@ -0,0 +1,29 @@ +'use server' + +import { ProjectModel } from "@/model/generated-zod"; +import { SuccessActionResult } from "@/model/server-action-error-return.model"; +import projectService from "@/server/services/project.service"; +import { getAuthUserSession, saveFormAction, simpleAction } from "@/server/utils/action-wrapper.utils"; +import { z } from "zod"; + +const createProjectSchema = z.object({ + projectName: z.string().min(1) +}); + +export const createProject = async (projectName: string) => + saveFormAction({ projectName }, createProjectSchema, async (validatedData) => { + await getAuthUserSession(); + + await projectService.save({ + name: validatedData.projectName + }); + + return new SuccessActionResult(undefined, "Project created successfully."); + }); + +export const deleteProject = async (projectId: string) => + simpleAction(async () => { + await getAuthUserSession(); + await projectService.deleteById(projectId); + return new SuccessActionResult(undefined, "Project deleted successfully."); + }); \ No newline at end of file diff --git a/src/app/projects/create-project-dialog.tsx b/src/app/projects/create-project-dialog.tsx new file mode 100644 index 0000000..0a547d2 --- /dev/null +++ b/src/app/projects/create-project-dialog.tsx @@ -0,0 +1,26 @@ +'use client' + +import { InputDialog } from "@/components/custom/input-dialog" +import { Button } from "@/components/ui/button" +import { Toast } from "@/lib/toast.utils"; +import { createProject } from "./actions"; + + +export function CreateProjectDialog() { + + const createProj = async (name: string | undefined) => { + if (!name) { + return true; + } + const result = await Toast.fromAction(() => createProject(name)); + return result.status === "success"; + }; + + return + + +} \ No newline at end of file diff --git a/src/app/projects/page.tsx b/src/app/projects/page.tsx new file mode 100644 index 0000000..3662795 --- /dev/null +++ b/src/app/projects/page.tsx @@ -0,0 +1,24 @@ +'use server' + +import { Button } from "@/components/ui/button"; + +import Link from "next/link"; +import { getAuthUserSession, getUserSession } from "@/server/utils/action-wrapper.utils"; +import projectService from "@/server/services/project.service"; +import ProjectsTable from "./projects-table"; +import { CreateProjectDialog } from "./create-project-dialog"; + +export default async function ProjectPage() { + + await getAuthUserSession(); + const data = await projectService.getAllProjects(); + return ( +
+
+

Projects

+ +
+ +
+ ) +} diff --git a/src/app/projects/projects-table.tsx b/src/app/projects/projects-table.tsx new file mode 100644 index 0000000..d7af5a9 --- /dev/null +++ b/src/app/projects/projects-table.tsx @@ -0,0 +1,55 @@ +'use client' + +import { Button } from "@/components/ui/button"; + +import Link from "next/link"; +import { SimpleDataTable } from "@/components/custom/simple-data-table"; +import { formatDateTime } from "@/lib/format.utils"; +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { MoreHorizontal } from "lucide-react"; +import { Toast } from "@/lib/toast.utils"; +import { Project } from "@prisma/client"; +import { deleteProject } from "./actions"; + + + +export default function ProjectsTable({ data }: { data: Project[] }) { + + return <> + formatDateTime(item.createdAt)], + ["updatedAt", "Updated At", false, (item) => formatDateTime(item.updatedAt)], + ]} + data={data} + onItemClickLink={(item) => `/project?id=${item.id}`} + actionCol={(item) => + <> +
+
+ + + + + + Actions + + + Show Apps of Project + + + + Toast.fromAction(() => deleteProject(item.id))}> + Delete Project + + + +
+ } + /> + +} \ No newline at end of file diff --git a/src/components/custom/code.tsx b/src/components/custom/code.tsx index d54fa67..a450767 100644 --- a/src/components/custom/code.tsx +++ b/src/components/custom/code.tsx @@ -1,6 +1,5 @@ 'use client' -import { Toast } from "@/lib/toast.utils"; import { toast } from "sonner"; export function Code({ children, copieable = true }: { children: string | null | undefined, copieable?: boolean }) { @@ -9,7 +8,7 @@ export function Code({ children, copieable = true }: { children: string | null | onClick={() => { if (!copieable) return; navigator.clipboard.writeText(children || ''); - toast.success('In die Zwischenablage kopiert'); + toast.success('Copied to clipboard'); }}> {children} diff --git a/src/components/custom/default-data-table.tsx b/src/components/custom/default-data-table.tsx index 1aa815a..0a391af 100644 --- a/src/components/custom/default-data-table.tsx +++ b/src/components/custom/default-data-table.tsx @@ -97,7 +97,7 @@ export function DefaultDataTable({
{!hideSearchBar && table.setGlobalFilter(String(event.target.value)) diff --git a/src/components/custom/input-dialog.tsx b/src/components/custom/input-dialog.tsx new file mode 100644 index 0000000..41460cd --- /dev/null +++ b/src/components/custom/input-dialog.tsx @@ -0,0 +1,100 @@ +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import React from "react"; +import LoadingSpinner from "../ui/loading-spinner"; +import { set } from "date-fns"; + +export function InputDialog({ + children, + title, + description, + fieldName, + OKButton = 'OK', + CancelButton = 'Cancel', + onResult +}: { + children: React.ReactNode; + title: string; + description: string; + fieldName: string; + OKButton?: string; + CancelButton?: string; + onResult: (result: string | undefined) => boolean | Promise; +}) { + + const [value, setValue] = React.useState(""); + const [isOpen, setIsOpen] = React.useState(false); + const [isLoading, setIsLoading] = React.useState(false); + const [errorMessages, setErrorMessages] = React.useState(""); + + const submit = async () => { + try { + if (!value) { + return; + } + setIsLoading(true); + setErrorMessages(""); + const result = await onResult(value); + if (result === true) { + setIsOpen(false); + setValue(""); + } else { + setErrorMessages(result || "An unexpected error occurred."); + } + } finally { + setIsLoading(false); + } + }; + + return ( + setIsOpen(isO)}> + + {children} + + + + {title} + + {description} + + +
+
+ + setValue(e.target.value)} + onKeyUp={(e) => { + if (e.key === "Enter") { + submit(); + } + }} + className="col-span-3" + /> +
+
+

{errorMessages}

+ + + + +
+
+ ) +} diff --git a/src/model/generated-zod/account.ts b/src/model/generated-zod/account.ts index 6736da2..0fb0328 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 new file mode 100644 index 0000000..52797be --- /dev/null +++ b/src/model/generated-zod/app.ts @@ -0,0 +1,39 @@ +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({ + id: z.string(), + name: z.string(), + projectId: z.string(), + gitUrl: z.string(), + gitBranch: z.string(), + gitUsername: z.string().nullish(), + gitToken: z.string().nullish(), + dockerfilePath: z.string(), + replicas: z.number().int(), + envVars: z.string(), + memoryReservation: z.number().int().nullish(), + memoryLimit: z.number().int().nullish(), + cpuReservation: z.number().int().nullish(), + cpuLimit: z.number().int().nullish(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteApp extends z.infer { + project: CompleteProject + appDomains: CompleteAppDomain[] + appVolumes: CompleteAppVolume[] +} + +/** + * RelatedAppModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppModel: z.ZodSchema = z.lazy(() => AppModel.extend({ + project: RelatedProjectModel, + appDomains: RelatedAppDomainModel.array(), + appVolumes: RelatedAppVolumeModel.array(), +})) diff --git a/src/model/generated-zod/appdomain.ts b/src/model/generated-zod/appdomain.ts new file mode 100644 index 0000000..bf55449 --- /dev/null +++ b/src/model/generated-zod/appdomain.ts @@ -0,0 +1,25 @@ +import * as z from "zod" +import * as imports from "../../../prisma/null" +import { CompleteApp, RelatedAppModel } from "./index" + +export const AppDomainModel = z.object({ + hostname: z.string(), + port: z.number().int(), + useSsl: z.boolean(), + appId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteAppDomain extends z.infer { + app: CompleteApp +} + +/** + * RelatedAppDomainModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppDomainModel: z.ZodSchema = z.lazy(() => AppDomainModel.extend({ + app: RelatedAppModel, +})) diff --git a/src/model/generated-zod/appvolume.ts b/src/model/generated-zod/appvolume.ts new file mode 100644 index 0000000..beabb2e --- /dev/null +++ b/src/model/generated-zod/appvolume.ts @@ -0,0 +1,23 @@ +import * as z from "zod" +import * as imports from "../../../prisma/null" +import { CompleteApp, RelatedAppModel } from "./index" + +export const AppVolumeModel = z.object({ + containerMountPath: z.string(), + appId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteAppVolume extends z.infer { + app: CompleteApp +} + +/** + * RelatedAppVolumeModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedAppVolumeModel: z.ZodSchema = z.lazy(() => AppVolumeModel.extend({ + app: RelatedAppModel, +})) diff --git a/src/model/generated-zod/authenticator.ts b/src/model/generated-zod/authenticator.ts index 6aafa7e..c65b1a2 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/index.ts b/src/model/generated-zod/index.ts index 39a6c14..fb23b47 100644 --- a/src/model/generated-zod/index.ts +++ b/src/model/generated-zod/index.ts @@ -3,3 +3,7 @@ export * from "./session" export * from "./user" export * from "./verificationtoken" export * from "./authenticator" +export * from "./project" +export * from "./app" +export * from "./appdomain" +export * from "./appvolume" diff --git a/src/model/generated-zod/project.ts b/src/model/generated-zod/project.ts new file mode 100644 index 0000000..e100fe5 --- /dev/null +++ b/src/model/generated-zod/project.ts @@ -0,0 +1,23 @@ +import * as z from "zod" +import * as imports from "../../../prisma/null" +import { CompleteApp, RelatedAppModel } from "./index" + +export const ProjectModel = z.object({ + id: z.string(), + name: z.string(), + createdAt: z.date(), + updatedAt: z.date(), +}) + +export interface CompleteProject extends z.infer { + apps: CompleteApp[] +} + +/** + * RelatedProjectModel contains all relations on your model in addition to the scalars + * + * NOTE: Lazy required in case of potential circular dependencies within schema + */ +export const RelatedProjectModel: z.ZodSchema = z.lazy(() => ProjectModel.extend({ + apps: RelatedAppModel.array(), +})) diff --git a/src/model/generated-zod/session.ts b/src/model/generated-zod/session.ts index 319a38f..45c0e73 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 4a9c1f6..d187007 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 c75a335..1df1715 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/project.service.ts b/src/server/services/project.service.ts new file mode 100644 index 0000000..fe3e587 --- /dev/null +++ b/src/server/services/project.service.ts @@ -0,0 +1,58 @@ +import { revalidateTag, unstable_cache } from "next/cache"; +import dataAccess from "../data-access/data-access.client"; +import { Tags } from "../utils/cache-tag-generator.utils"; +import { Prisma, Project } from "@prisma/client"; +import { DefaultArgs } from "@prisma/client/runtime/library"; + +class ProjectService { + + async deleteById(id: string) { + const existingItem = await this.getById(id); + if (!existingItem) { + return; + } + await dataAccess.client.project.delete({ + where: { + id + } + }); + revalidateTag(Tags.projects()); + } + + async getAllProjects() { + return await unstable_cache(async () => await dataAccess.client.project.findMany(), + [Tags.projects()], { + tags: [Tags.projects()] + })(); + } + + async getById(id: string) { + return dataAccess.client.project.findUnique({ + where: { + id + } + }); + } + + async save(property: Prisma.ProjectUncheckedCreateInput | Prisma.ProjectUncheckedUpdateInput) { + let savedItem: Prisma.Prisma__ProjectClient; + if (property.id) { + savedItem = dataAccess.client.project.update({ + where: { + id: property.id as string + }, + data: property + }); + } else { + savedItem = dataAccess.client.project.create({ + data: property as Prisma.ProjectUncheckedCreateInput + }); + } + + revalidateTag(Tags.projects()); + return savedItem; + } +} + +const projectService = new ProjectService(); +export default projectService; diff --git a/src/server/services/real-estate.service.ts b/src/server/services/real-estate.service.ts deleted file mode 100644 index f0c6f28..0000000 --- a/src/server/services/real-estate.service.ts +++ /dev/null @@ -1,79 +0,0 @@ -/* - -import { Prisma, RealEstate } from "@prisma/client"; -import dataAccess from "../data-access/data-access.client"; -import { revalidateTag, unstable_cache } from "next/cache"; -import { Tags } from "../utils/cache-tag-generator.utils"; -import { DefaultArgs } from "@prisma/client/runtime/library"; - - -export class RealEstateService { - - async deleteRealEstate(id: string) { - const existingItem = await this.getRealEstateById(id); - if (!existingItem) { - return; - } - await dataAccess.client.realEstate.delete({ - where: { - id - } - }); - revalidateTag(Tags.realEstates(existingItem.landlordId)); - } - - async getRealEstatesForLandlord(landlordId: string) { - return await unstable_cache(async (landlordId: string) => await dataAccess.client.realEstate.findMany({ - where: { - landlordId - } - }), - [Tags.realEstates(landlordId)], { - tags: [Tags.realEstates(landlordId)] - })(landlordId); - } - - async getRealEstateById(id: string) { - return dataAccess.client.realEstate.findUnique({ - where: { - id - } - }); - } - - async getCurrentLandlordIdFromRealEstate(realEstateId: string) { - const property = await dataAccess.client.realEstate.findUnique({ - select: { - landlordId: true - }, - where: { - id: realEstateId - } - }); - return property?.landlordId; - } - - async saveRealEstate(property: Prisma.RealEstateUncheckedCreateInput | Prisma.RealEstateUncheckedUpdateInput) { - - let savedProperty: Prisma.Prisma__RealEstateClient; - if (property.id) { - savedProperty = dataAccess.client.realEstate.update({ - where: { - id: property.id as string - }, - data: property - }); - } else { - savedProperty = dataAccess.client.realEstate.create({ - data: property as Prisma.RealEstateUncheckedCreateInput - }); - } - - revalidateTag(Tags.realEstates(property.landlordId as string)); - return savedProperty; - } - -} - -const realEstateService = new RealEstateService(); -export default realEstateService;*/ \ No newline at end of file diff --git a/src/server/utils/action-wrapper.utils.ts b/src/server/utils/action-wrapper.utils.ts index 5732453..58314c6 100644 --- a/src/server/utils/action-wrapper.utils.ts +++ b/src/server/utils/action-wrapper.utils.ts @@ -7,10 +7,22 @@ import { ServerActionResult, SuccessActionResult } from "@/model/server-action-e import { FormValidationException } from "@/model/form-validation-exception.model"; import { authOptions } from "@/lib/auth-options"; +/** + * THIS FUNCTION RETURNS NULL IF NO USER IS LOGGED IN + * use getAuthUserSession() if you want to throw an error if no user is logged in + */ export async function getUserSession(): Promise { const session = await getServerSession(authOptions) as UserSession | null; return session; } + +export async function getAuthUserSession(): Promise { + const session = await getUserSession(); + if (!session) { + throw new ServiceException('User is not authenticated.'); + } + return session; +} /* export async function checkIfCurrentUserHasAccessToContract(contractId: string | null | undefined) { const session = await getLandlordSession(); diff --git a/src/server/utils/cache-tag-generator.utils.ts b/src/server/utils/cache-tag-generator.utils.ts index c76d86f..3a111fe 100644 --- a/src/server/utils/cache-tag-generator.utils.ts +++ b/src/server/utils/cache-tag-generator.utils.ts @@ -3,4 +3,8 @@ export class Tags { static users() { return `users`; } + + static projects() { + return `projects`; + } } \ No newline at end of file