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 (
+
+ )
+}
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 (
+
+ )
+}
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