feat: delete workspace

This commit is contained in:
Henry
2024-08-30 22:28:07 +01:00
parent 5c8a1c5069
commit a06500a949
19 changed files with 1647 additions and 365 deletions

View File

@@ -1,7 +1,7 @@
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> { interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary"; variant?: "primary" | "secondary" | "danger";
isLoading?: boolean; isLoading?: boolean;
} }
@@ -19,8 +19,11 @@ const Button = ({
"bg-light-1000 dark:bg-dark-1000 dark:text-dark-50", "bg-light-1000 dark:bg-dark-1000 dark:text-dark-50",
variant === "secondary" && variant === "secondary" &&
"border-[1px] border-light-600 bg-light-50 dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000", "border-[1px] border-light-600 bg-light-50 dark:border-dark-600 dark:bg-dark-300 dark:text-dark-1000",
variant === "danger" &&
"dark:text-red-1000 border-[1px] border-red-600 bg-red-50 dark:border-red-600 dark:bg-red-500",
props.disabled && "opacity-50",
)} )}
disabled={isLoading} disabled={isLoading ?? props.disabled}
{...props} {...props}
> >
<span className="relative flex items-center justify-center"> <span className="relative flex items-center justify-center">

View File

@@ -1,9 +1,14 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { api } from "~/utils/api"; import { api } from "~/utils/api";
import { HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { useWorkspace } from "~/providers/workspace";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { HiXMark } from "react-icons/hi2";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
import Input from "~/components/Input";
interface FormValues { interface FormValues {
name: string; name: string;
@@ -11,20 +16,23 @@ interface FormValues {
export function NewWorkspaceForm() { export function NewWorkspaceForm() {
const { closeModal } = useModal(); const { closeModal } = useModal();
const { showPopup } = usePopup();
const { switchWorkspace } = useWorkspace(); const { switchWorkspace } = useWorkspace();
const { register, handleSubmit } = useForm<FormValues>(); const { register, handleSubmit } = useForm<FormValues>();
const createWorkspace = api.workspace.create.useMutation({ const createWorkspace = api.workspace.create.useMutation({
onSuccess: (values) => { onSuccess: (values) => {
try { if (values?.publicId && values.name) {
if (values?.publicId && values.name) { switchWorkspace({ publicId: values.publicId, name: values.name });
switchWorkspace({ publicId: values.publicId, name: values.name }); closeModal();
closeModal();
}
} catch (e) {
console.log(e);
} }
}, },
onError: () => {
showPopup({
header: "Unable to create workspace",
message: "Please try again later, or contact customer support.",
});
},
}); });
useEffect(() => { useEffect(() => {
@@ -40,40 +48,36 @@ export function NewWorkspaceForm() {
}; };
return ( return (
<div className="p-5"> <form onSubmit={handleSubmit(onSubmit)}>
<div className="flex w-full items-center justify-between pb-4"> <div className="px-5 pt-5">
<h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000"> <div className="flex w-full items-center justify-between pb-4">
New workspace <h2 className="text-sm font-bold text-neutral-900 dark:text-dark-1000">
</h2> New workspace
<button </h2>
className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
onClick={() => closeModal()}
>
<HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button>
</div>
<form onSubmit={handleSubmit(onSubmit)}>
<label
htmlFor="workspace-name"
className="block pb-2 text-sm font-normal leading-6 text-neutral-900 dark:text-dark-1000"
>
Name
</label>
<input
id="workspace-name"
{...register("name", { required: true })}
className="block w-full rounded-md border-0 bg-dark-300 bg-white/5 py-1.5 text-neutral-900 shadow-sm ring-1 ring-inset ring-light-600 focus:ring-2 focus:ring-inset focus:ring-light-600 dark:text-dark-1000 dark:ring-dark-700 dark:focus:ring-dark-700 sm:text-sm sm:leading-6"
/>
<div className="mt-5 sm:mt-6">
<button <button
type="submit" className="rounded p-1 hover:bg-light-200 focus:outline-none dark:hover:bg-dark-300"
className="inline-flex w-full justify-center rounded-md bg-light-1000 px-3 py-2 text-sm font-semibold text-light-50 shadow-sm focus-visible:outline-none dark:bg-dark-1000 dark:text-dark-50" onClick={(e) => {
e.preventDefault();
closeModal();
}}
> >
Create workspace <HiXMark size={18} className="text-light-900 dark:text-dark-900" />
</button> </button>
</div> </div>
</form>
</div> <Input
id="workspace-name"
placeholder="Workspace name"
{...register("name")}
/>
</div>
<div className="mt-12 flex items-center justify-end border-t border-light-600 px-5 pb-5 pt-5 dark:border-dark-600">
<div>
<Button type="submit" isLoading={createWorkspace.isPending}>
Create workspace
</Button>
</div>
</div>
</form>
); );
} }

View File

@@ -114,20 +114,20 @@ export const boardRouter = createTRPCRouter({
const deletedAt = new Date().toISOString(); const deletedAt = new Date().toISOString();
await boardRepo.destroy(ctx.db, { await boardRepo.softDelete(ctx.db, {
boardId: board.id, boardId: board.id,
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,
}); });
if (listIds.length) { if (listIds.length) {
await listRepo.destroyAllByBoardId(ctx.db, { await listRepo.softDeleteAllByBoardId(ctx.db, {
boardId: board.id, boardId: board.id,
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,
}); });
await cardRepo.destroyAllByListIds(ctx.db, { await cardRepo.softDeleteAllByListIds(ctx.db, {
listIds, listIds,
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,

View File

@@ -149,7 +149,7 @@ export const cardRouter = createTRPCRouter({
); );
if (existingLabel) { if (existingLabel) {
await cardRepo.destroyCardLabelRelationship(ctx.db, cardLabelIds); await cardRepo.hardDeleteCardLabelRelationship(ctx.db, cardLabelIds);
return { newLabel: false }; return { newLabel: false };
} }
@@ -200,7 +200,7 @@ export const cardRouter = createTRPCRouter({
); );
if (existingMember) { if (existingMember) {
await cardRepo.destroyCardMemberRelationship(ctx.db, cardMemberIds); await cardRepo.hardDeleteCardMemberRelationship(ctx.db, cardMemberIds);
return { newMember: false }; return { newMember: false };
} }
@@ -272,7 +272,7 @@ export const cardRouter = createTRPCRouter({
const deletedAt = new Date().toISOString(); const deletedAt = new Date().toISOString();
await cardRepo.destroy(ctx.db, { await cardRepo.softDelete(ctx.db, {
cardId: card.id, cardId: card.id,
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,

View File

@@ -81,9 +81,9 @@ export const labelRouter = createTRPCRouter({
code: "NOT_FOUND", code: "NOT_FOUND",
}); });
await cardRepo.destroyAllCardLabelRelationships(ctx.db, label.id); await cardRepo.hardDeleteAllCardLabelRelationships(ctx.db, label.id);
await labelRepo.destroy(ctx.db, label.id); await labelRepo.hardDelete(ctx.db, label.id);
return { success: true }; return { success: true };
}), }),

View File

@@ -98,13 +98,13 @@ export const listRouter = createTRPCRouter({
const deletedAt = new Date().toISOString(); const deletedAt = new Date().toISOString();
await listRepo.destroyById(ctx.db, { await listRepo.softDeleteById(ctx.db, {
listId: list.id, listId: list.id,
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,
}); });
await cardRepo.destroyAllByListIds(ctx.db, { await cardRepo.softDeleteAllByListIds(ctx.db, {
listIds: [list.id], listIds: [list.id],
deletedAt, deletedAt,
deletedBy: userId, deletedBy: userId,

View File

@@ -49,6 +49,22 @@ export const workspaceRouter = createTRPCRouter({
createdBy: userId, createdBy: userId,
}); });
if (!result?.publicId)
throw new TRPCError({
message: `Unable to create workspace`,
code: "INTERNAL_SERVER_ERROR",
});
return result; return result;
}), }),
delete: protectedProcedure
.input(z.object({ workspacePublicId: z.string().min(12) }))
.mutation(async ({ ctx, input }) => {
const { data } = await workspaceRepo.hardDelete(
ctx.db,
input.workspacePublicId,
);
return data;
}),
}); });

View File

@@ -0,0 +1,66 @@
DROP TABLE IF EXISTS "account";--> statement-breakpoint
DROP TABLE IF EXISTS "session";--> statement-breakpoint
DROP TABLE IF EXISTS "verificationToken";--> statement-breakpoint
ALTER TABLE "board" DROP CONSTRAINT "board_workspaceId_workspace_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_workspace_members" DROP CONSTRAINT "_card_workspace_members_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "card" DROP CONSTRAINT "card_listId_list_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT IF EXISTS "_card_labels_cardId_card_id_fk";
--> statement-breakpoint
ALTER TABLE "_card_labels" DROP CONSTRAINT IF EXISTS "_card_labels_labelId_label_id_fk";
--> statement-breakpoint
ALTER TABLE "label" DROP CONSTRAINT "label_boardId_board_id_fk";
--> statement-breakpoint
ALTER TABLE "list" DROP CONSTRAINT "list_boardId_board_id_fk";
--> statement-breakpoint
ALTER TABLE "workspace_members" DROP CONSTRAINT "workspace_members_workspaceId_workspace_id_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "board" ADD CONSTRAINT "board_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_workspace_members" ADD CONSTRAINT "_card_workspace_members_workspaceMemberId_workspace_members_id_fk" FOREIGN KEY ("workspaceMemberId") REFERENCES "workspace_members"("id") ON DELETE CASCADE ON UPDATE NO ACTION;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "card" ADD CONSTRAINT "card_listId_list_id_fk" FOREIGN KEY ("listId") REFERENCES "list"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_cardId_card_id_fk" FOREIGN KEY ("cardId") REFERENCES "card"("id") ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "_card_labels" ADD CONSTRAINT "_card_labels_labelId_label_id_fk" FOREIGN KEY ("labelId") REFERENCES "label"("id") ON DELETE CASCADE;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "label" ADD CONSTRAINT "label_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "list" ADD CONSTRAINT "list_boardId_board_id_fk" FOREIGN KEY ("boardId") REFERENCES "board"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_members" ADD CONSTRAINT "workspace_members_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "workspace"("id") ON DELETE cascade ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,13 @@
"when": 1713021051974, "when": 1713021051974,
"tag": "0001_stale_mattie_franklin", "tag": "0001_stale_mattie_franklin",
"breakpoints": true "breakpoints": true
},
{
"idx": 2,
"version": "5",
"when": 1724967733894,
"tag": "0002_clever_robin_chapel",
"breakpoints": true
} }
] ]
} }

View File

@@ -144,7 +144,7 @@ export const update = async (
return data; return data;
}; };
export const destroy = async ( export const softDelete = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
args: { args: {
boardId: number; boardId: number;
@@ -160,3 +160,12 @@ export const destroy = async (
return result; return result;
}; };
export const hardDelete = async (
db: SupabaseClient<Database>,
workspaceId: number,
) => {
const result = db.from("board").delete().eq("workspaceId", workspaceId);
return result;
};

View File

@@ -74,39 +74,6 @@ export const update = async (
return data; return data;
}; };
export const destroy = async (
db: SupabaseClient<Database>,
args: {
cardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.cardId);
return result;
};
export const destroyAllByListIds = async (
db: SupabaseClient<Database>,
args: {
listIds: number[];
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.in("listId", args.listIds)
.is("deletedAt", null);
return result;
};
export const getCardWithListByPublicId = async ( export const getCardWithListByPublicId = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
cardPublicId: string, cardPublicId: string,
@@ -168,28 +135,6 @@ export const bulkCreate = async (
return data; return data;
}; };
export const destroyCardLabelRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; labelId: number },
) => {
const result = await db
.from("_card_labels")
.delete()
.eq("cardId", args.cardId)
.eq("labelId", args.labelId);
return result;
};
export const destroyAllCardLabelRelationships = async (
db: SupabaseClient<Database>,
labelId: number,
) => {
const result = await db.from("_card_labels").delete().eq("labelId", labelId);
return result;
};
export const createCardLabelRelationship = async ( export const createCardLabelRelationship = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
cardLabelRelationshipInput: { cardId: number; labelId: number }, cardLabelRelationshipInput: { cardId: number; labelId: number },
@@ -217,19 +162,6 @@ export const getCardMemberRelationship = async (
return data; return data;
}; };
export const destroyCardMemberRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; memberId: number },
) => {
const result = await db
.from("_card_workspace_members")
.delete()
.eq("cardId", args.cardId)
.eq("workspaceMemberId", args.memberId);
return result;
};
export const createCardMemberRelationship = async ( export const createCardMemberRelationship = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
cardMemberRelationshipInput: { cardId: number; memberId: number }, cardMemberRelationshipInput: { cardId: number; memberId: number },
@@ -353,3 +285,71 @@ export const pushIndex = async (
return data; return data;
}; };
export const softDelete = async (
db: SupabaseClient<Database>,
args: {
cardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.cardId);
return result;
};
export const softDeleteAllByListIds = async (
db: SupabaseClient<Database>,
args: {
listIds: number[];
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("card")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.in("listId", args.listIds)
.is("deletedAt", null);
return result;
};
export const hardDeleteCardMemberRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; memberId: number },
) => {
const result = await db
.from("_card_workspace_members")
.delete()
.eq("cardId", args.cardId)
.eq("workspaceMemberId", args.memberId);
return result;
};
export const hardDeleteCardLabelRelationship = async (
db: SupabaseClient<Database>,
args: { cardId: number; labelId: number },
) => {
const result = await db
.from("_card_labels")
.delete()
.eq("cardId", args.cardId)
.eq("labelId", args.labelId);
return result;
};
export const hardDeleteAllCardLabelRelationships = async (
db: SupabaseClient<Database>,
labelId: number,
) => {
const result = await db.from("_card_labels").delete().eq("labelId", labelId);
return result;
};

View File

@@ -79,7 +79,7 @@ export const update = async (
return data; return data;
}; };
export const destroy = async ( export const hardDelete = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
labelId: number, labelId: number,
) => { ) => {

View File

@@ -85,40 +85,6 @@ export const update = async (
return data; return data;
}; };
export const destroyAllByBoardId = async (
db: SupabaseClient<Database>,
args: {
boardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("boardId", args.boardId)
.is("deletedAt", null);
return result;
};
export const destroyById = async (
db: SupabaseClient<Database>,
args: {
listId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.listId)
.is("deletedAt", null);
return result;
};
export const reorder = async ( export const reorder = async (
db: SupabaseClient<Database>, db: SupabaseClient<Database>,
args: { args: {
@@ -152,3 +118,37 @@ export const shiftIndex = async (
return data; return data;
}; };
export const softDeleteAllByBoardId = async (
db: SupabaseClient<Database>,
args: {
boardId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("boardId", args.boardId)
.is("deletedAt", null);
return result;
};
export const softDeleteById = async (
db: SupabaseClient<Database>,
args: {
listId: number;
deletedAt: string;
deletedBy: string;
},
) => {
const result = await db
.from("list")
.update({ deletedAt: args.deletedAt, deletedBy: args.deletedBy })
.eq("id", args.listId)
.is("deletedAt", null);
return result;
};

View File

@@ -127,3 +127,15 @@ export const getAllMembersByPublicIds = async (
return data; return data;
}; };
export const hardDelete = async (
db: SupabaseClient<Database>,
workspacePublicId: string,
) => {
const result = db
.from("workspace")
.delete()
.eq("publicId", workspacePublicId);
return result;
};

View File

@@ -12,217 +12,237 @@ import {
bigint, bigint,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
export const importSourceEnum = pgEnum('source', ['trello']); export const importSourceEnum = pgEnum("source", ["trello"]);
export const importStatusEnum = pgEnum('status', ['started', 'success', 'failed']); export const importStatusEnum = pgEnum("status", [
export const memberRoleEnum = pgEnum('role', ['admin', 'member', 'guest']); "started",
"success",
"failed",
]);
export const memberRoleEnum = pgEnum("role", ["admin", "member", "guest"]);
export const boards = pgTable( export const boards = pgTable("board", {
"board", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), name: varchar("name", { length: 255 }).notNull(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(), createdBy: uuid("createdBy")
name: varchar("name", { length: 255 }).notNull(), .notNull()
createdBy: uuid("createdBy").notNull().references(() => users.id), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"), updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"), deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id), deletedBy: uuid("deletedBy").references(() => users.id),
importId: bigint("importId", { mode: "number" }).references(() => imports.id), importId: bigint("importId", { mode: "number" }).references(() => imports.id),
workspaceId: bigint("workspaceId", { mode: "number" }).notNull().references(() => workspaces.id), workspaceId: bigint("workspaceId", { mode: "number" })
}, .notNull()
); .references(() => workspaces.id, { onDelete: "cascade" }),
});
export const boardsRelations = relations(boards, ({ one, many }) => ({ export const boardsRelations = relations(boards, ({ one, many }) => ({
createdBy: one(users, { createdBy: one(users, {
fields: [boards.createdBy], fields: [boards.createdBy],
references: [users.id], references: [users.id],
}), }),
lists: many(lists), lists: many(lists),
labels: many(labels), labels: many(labels),
deletedBy: one(users, { deletedBy: one(users, {
fields: [boards.deletedBy], fields: [boards.deletedBy],
references: [users.id], references: [users.id],
}), }),
import: one(imports, { import: one(imports, {
fields: [boards.importId], fields: [boards.importId],
references: [imports.id], references: [imports.id],
}), }),
workspace: one(workspaces, { workspace: one(workspaces, {
fields: [boards.workspaceId], fields: [boards.workspaceId],
references: [workspaces.id], references: [workspaces.id],
}), }),
})); }));
export const imports = pgTable( export const imports = pgTable("import", {
"import", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), source: importSourceEnum("source").notNull(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(), status: importStatusEnum("status").notNull(),
source: importSourceEnum('source').notNull(), createdBy: uuid("createdBy")
status: importStatusEnum('status').notNull(), .notNull()
createdBy: uuid("createdBy").notNull().references(() => users.id), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
}, });
);
export const importsRelations = relations(imports, ({ one, many }) => ({ export const importsRelations = relations(imports, ({ one, many }) => ({
createdBy: one(users, { createdBy: one(users, {
fields: [imports.createdBy], fields: [imports.createdBy],
references: [users.id], references: [users.id],
}), }),
boards: many(boards), boards: many(boards),
cards: many(cards), cards: many(cards),
lists: many(lists), lists: many(lists),
labels: many(labels) labels: many(labels),
})); }));
export const labels = pgTable( export const labels = pgTable("label", {
"label", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), name: varchar("name", { length: 255 }).notNull(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(), colourCode: varchar("colourCode", { length: 12 }),
name: varchar("name", { length: 255 }).notNull(), createdBy: uuid("createdBy")
colourCode: varchar("colourCode", { length: 12 }), .notNull()
createdBy: uuid("createdBy").notNull().references(() => users.id), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"), updatedAt: timestamp("updatedAt"),
boardId: bigint("boardId", { mode: "number" }).notNull().references(() => boards.id), boardId: bigint("boardId", { mode: "number" })
importId: bigint("importId", { mode: "number" }).references(() => imports.id), .notNull()
} .references(() => boards.id, { onDelete: "cascade" }),
); importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const labelsRelations = relations(labels, ({ one, many }) => ({ export const labelsRelations = relations(labels, ({ one, many }) => ({
createdBy: one(users, { createdBy: one(users, {
fields: [labels.createdBy], fields: [labels.createdBy],
references: [users.id], references: [users.id],
}), }),
board: one(boards, { board: one(boards, {
fields: [labels.boardId], fields: [labels.boardId],
references: [boards.id], references: [boards.id],
}), }),
cards: many(cardsToLabels), cards: many(cardsToLabels),
import: one(imports, { import: one(imports, {
fields: [labels.importId], fields: [labels.importId],
references: [imports.id], references: [imports.id],
}), }),
})); }));
export const cardsToLabels = pgTable( export const cardsToLabels = pgTable(
"_card_labels", "_card_labels",
{ {
cardId: bigint("cardId", { mode: "number" }).notNull().references(() => cards.id), cardId: bigint("cardId", { mode: "number" })
labelId: bigint("labelId", { mode: "number" }).notNull().references(() => labels.id), .notNull()
}, (t) => ({ .references(() => cards.id),
labelId: bigint("labelId", { mode: "number" })
.notNull()
.references(() => labels.id, { onDelete: "cascade" }),
},
(t) => ({
pk: primaryKey(t.cardId, t.labelId), pk: primaryKey(t.cardId, t.labelId),
}), }),
); );
export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({ export const cardToLabelsRelations = relations(cardsToLabels, ({ one }) => ({
card: one(cards, { card: one(cards, {
fields: [cardsToLabels.cardId], fields: [cardsToLabels.cardId],
references: [cards.id], references: [cards.id],
}), }),
label: one(labels, { label: one(labels, {
fields: [cardsToLabels.labelId], fields: [cardsToLabels.labelId],
references: [labels.id], references: [labels.id],
}), }),
})); }));
export const cardToWorkspaceMembers = pgTable( export const cardToWorkspaceMembers = pgTable(
"_card_workspace_members", "_card_workspace_members",
{ {
cardId: bigint("cardId", { mode: "number" }).notNull().references(() => cards.id), cardId: bigint("cardId", { mode: "number" })
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" }).notNull().references(() => workspaceMembers.id), .notNull()
}, (t) => ({ .references(() => cards.id),
workspaceMemberId: bigint("workspaceMemberId", { mode: "number" })
.notNull()
.references(() => workspaceMembers.id, { onDelete: "cascade" }),
},
(t) => ({
pk: primaryKey(t.cardId, t.workspaceMemberId), pk: primaryKey(t.cardId, t.workspaceMemberId),
}), }),
); );
export const cardToWorkspaceMembersRelations = relations(cardToWorkspaceMembers, ({ one }) => ({ export const cardToWorkspaceMembersRelations = relations(
card: one(cards, { cardToWorkspaceMembers,
fields: [cardToWorkspaceMembers.cardId], ({ one }) => ({
references: [cards.id], card: one(cards, {
}), fields: [cardToWorkspaceMembers.cardId],
member: one(workspaceMembers, { references: [cards.id],
fields: [cardToWorkspaceMembers.workspaceMemberId], }),
references: [workspaceMembers.id], member: one(workspaceMembers, {
}), fields: [cardToWorkspaceMembers.workspaceMemberId],
})); references: [workspaceMembers.id],
}),
export const lists = pgTable( }),
"list",
{
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
createdBy: uuid("createdBy").notNull().references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
boardId: bigint("boardId", { mode: "number" }).notNull().references(() => boards.id),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
}
); );
export const lists = pgTable("list", {
id: bigserial("id", { mode: "number" }).primaryKey(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(),
index: integer("index").notNull(),
createdBy: uuid("createdBy")
.notNull()
.references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id),
boardId: bigint("boardId", { mode: "number" })
.notNull()
.references(() => boards.id, { onDelete: "cascade" }),
importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const listsRelations = relations(lists, ({ one, many }) => ({ export const listsRelations = relations(lists, ({ one, many }) => ({
createdBy: one(users, { createdBy: one(users, {
fields: [lists.createdBy], fields: [lists.createdBy],
references: [users.id], references: [users.id],
}), }),
board: one(boards, { board: one(boards, {
fields: [lists.boardId], fields: [lists.boardId],
references: [boards.id], references: [boards.id],
}), }),
cards: many(cards), cards: many(cards),
deletedBy: one(users, { deletedBy: one(users, {
fields: [lists.deletedBy], fields: [lists.deletedBy],
references: [users.id], references: [users.id],
}), }),
import: one(imports, { import: one(imports, {
fields: [lists.importId], fields: [lists.importId],
references: [imports.id], references: [imports.id],
}), }),
})); }));
export const cards = pgTable( export const cards = pgTable("card", {
"card", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), title: varchar("title", { length: 255 }).notNull(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(), description: text("description"),
title: varchar("title", { length: 255 }).notNull(), index: integer("index").notNull(),
description: text("description"), createdBy: uuid("createdBy")
index: integer("index").notNull(), .notNull()
createdBy: uuid("createdBy").notNull().references(() => users.id), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"), updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"), deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id), deletedBy: uuid("deletedBy").references(() => users.id),
listId: bigint("listId", { mode: "number" }).notNull().references(() => lists.id), listId: bigint("listId", { mode: "number" })
importId: bigint("importId", { mode: "number" }).references(() => imports.id), .notNull()
} .references(() => lists.id, { onDelete: "cascade" }),
); importId: bigint("importId", { mode: "number" }).references(() => imports.id),
});
export const cardsRelations = relations(cards, ({ one, many }) => ({ export const cardsRelations = relations(cards, ({ one, many }) => ({
createdBy: one(users, { createdBy: one(users, {
fields: [cards.createdBy], fields: [cards.createdBy],
references: [users.id], references: [users.id],
}), }),
list: one(lists, { list: one(lists, {
fields: [cards.listId], fields: [cards.listId],
references: [lists.id], references: [lists.id],
}), }),
deletedBy: one(users, { deletedBy: one(users, {
fields: [cards.deletedBy], fields: [cards.deletedBy],
references: [users.id], references: [users.id],
}), }),
labels: many(cardsToLabels), labels: many(cardsToLabels),
members: many(cardToWorkspaceMembers), members: many(cardToWorkspaceMembers),
import: one(imports, { import: one(imports, {
fields: [cards.importId], fields: [cards.importId],
references: [imports.id], references: [imports.id],
}), }),
})); }));
export const users = pgTable("user", { export const users = pgTable("user", {
@@ -241,51 +261,55 @@ export const usersRelations = relations(users, ({ many }) => ({
workspaces: many(workspaces), workspaces: many(workspaces),
})); }));
export const workspaces = pgTable( export const workspaces = pgTable("workspace", {
"workspace", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), name: varchar("name", { length: 255 }).notNull(),
publicId: varchar("publicId", { length: 12 }).notNull().unique(), slug: varchar("slug", { length: 255 }).notNull().unique(),
name: varchar("name", { length: 255 }).notNull(), createdBy: uuid("createdBy")
slug: varchar("slug", { length: 255 }).notNull().unique(), .notNull()
createdBy: uuid("createdBy").notNull().references(() => users.id), .references(() => users.id),
createdAt: timestamp("createdAt").defaultNow().notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(),
updatedAt: timestamp("updatedAt"), updatedAt: timestamp("updatedAt"),
deletedAt: timestamp("deletedAt"), deletedAt: timestamp("deletedAt"),
deletedBy: uuid("deletedBy").references(() => users.id), deletedBy: uuid("deletedBy").references(() => users.id),
} });
);
export const workspaceRelations = relations(workspaces, ({ one, many }) => ({ export const workspaceRelations = relations(workspaces, ({ one, many }) => ({
user: one(users, { fields: [workspaces.createdBy], references: [users.id] }), user: one(users, { fields: [workspaces.createdBy], references: [users.id] }),
members: many(workspaceMembers) members: many(workspaceMembers),
})); }));
export const workspaceMembers = pgTable( export const workspaceMembers = pgTable("workspace_members", {
"workspace_members", id: bigserial("id", { mode: "number" }).primaryKey(),
{ publicId: varchar("publicId", { length: 12 }).notNull().unique(),
id: bigserial("id", { mode: "number" }).primaryKey(), userId: uuid("userId")
publicId: varchar("publicId", { length: 12 }).notNull().unique(), .notNull()
userId: uuid("userId").notNull().references(() => users.id), .references(() => users.id),
workspaceId: bigint("workspaceId", { mode: "number" }).notNull().references(() => workspaces.id), workspaceId: bigint("workspaceId", { mode: "number" })
createdBy: uuid("createdBy").notNull(), .notNull()
createdAt: timestamp("createdAt").defaultNow().notNull(), .references(() => workspaces.id, { onDelete: "cascade" }),
updatedAt: timestamp("updatedAt"), createdBy: uuid("createdBy").notNull(),
deletedAt: timestamp("deletedAt"), createdAt: timestamp("createdAt").defaultNow().notNull(),
role: memberRoleEnum('role').notNull(), updatedAt: timestamp("updatedAt"),
}, deletedAt: timestamp("deletedAt"),
role: memberRoleEnum("role").notNull(),
});
export const usersToWorkspacesRelations = relations(
workspaceMembers,
({ one }) => ({
addedBy: one(users, {
fields: [workspaceMembers.createdBy],
references: [users.id],
}),
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
}),
workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId],
references: [workspaces.id],
}),
}),
); );
export const usersToWorkspacesRelations = relations(workspaceMembers, ({ one }) => ({
addedBy: one(users, { fields: [workspaceMembers.createdBy], references: [users.id] }),
user: one(users, {
fields: [workspaceMembers.userId],
references: [users.id],
}),
workspace: one(workspaces, {
fields: [workspaceMembers.workspaceId],
references: [workspaces.id],
}),
}));

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import { api } from "~/utils/api";
import { useModal } from "~/providers/modal";
import { usePopup } from "~/providers/popup";
import { useWorkspace } from "~/providers/workspace";
import Button from "~/components/Button";
export function DeleteWorkspaceConfirmation() {
const { closeModal } = useModal();
const { workspace, switchWorkspace, availableWorkspaces } = useWorkspace();
const { showPopup } = usePopup();
const router = useRouter();
const [isAcknowledgmentChecked, setIsAcknowledgmentChecked] = useState(false);
const deleteWorkspaceMutation = api.workspace.delete.useMutation({
onSuccess: () => {
closeModal();
const filteredWorkspaces = availableWorkspaces.filter(
(ws) => ws.publicId !== workspace?.publicId,
);
if (filteredWorkspaces.length > 0 && filteredWorkspaces[0]) {
switchWorkspace(filteredWorkspaces[0]);
} else {
router.push("/");
}
},
onError: () => {
closeModal();
showPopup({
header: "Error deleting workspace",
message: "Please try again later, or contact customer support.",
});
},
});
const handleDeleteWorkspace = () => {
deleteWorkspaceMutation.mutate({
workspacePublicId: workspace?.publicId,
});
};
return (
<div className="p-5">
<div className="flex w-full flex-col justify-between pb-4">
<h2 className="text-md pb-4 font-medium text-neutral-900 dark:text-dark-1000">
{`Are you sure you want to delete the workspace ${workspace?.name}?`}
</h2>
<p className="mb-4 text-sm text-light-900 dark:text-dark-900">
Keep in mind that this action is irreversible.
</p>
<p className="text-sm text-light-900 dark:text-dark-900">
This will result in the permanent deletion of all data associated with
this workspace.
</p>
</div>
<div className="relative flex items-start">
<div className="flex h-6 items-center">
<input
id="acknowledgment"
name="acknowledgment"
type="checkbox"
aria-describedby="acknowledgment-description"
className="mt-2 h-[14px] w-[14px] rounded border-gray-300 bg-transparent text-indigo-600 focus:shadow-none focus:ring-0 focus:ring-offset-0"
checked={isAcknowledgmentChecked}
onChange={() =>
setIsAcknowledgmentChecked(!isAcknowledgmentChecked)
}
/>
</div>
<div className="ml-3 text-sm leading-6">
<p id="comments-description" className="text-dark-1000">
I acknowledge that all of the workspace data will be permanently
deleted and want to proceed.
</p>
</div>
</div>
<div className="mt-5 flex justify-end space-x-2 sm:mt-6">
<Button variant="secondary" onClick={() => closeModal()}>
Cancel
</Button>
<Button
variant="danger"
onClick={handleDeleteWorkspace}
disabled={!isAcknowledgmentChecked}
isLoading={deleteWorkspaceMutation.isPending}
>
Delete workspace
</Button>
</div>
</div>
);
}

View File

@@ -1,10 +1,10 @@
import { useModal } from "~/providers/modal"; import { useModal } from "~/providers/modal";
import Modal from "~/components/modal"; import Modal from "~/components/modal";
import Button from "~/components/Button";
import { NewWorkspaceForm } from "~/components/NewWorkspaceForm"; import { DeleteWorkspaceConfirmation } from "./components/DeleteWorkspaceConfirmation";
export default function SettingsPage() { export default function SettingsPage() {
const { modalContentType } = useModal(); const { modalContentType, openModal } = useModal();
return ( return (
<div className="px-28 py-12"> <div className="px-28 py-12">
@@ -14,16 +14,23 @@ export default function SettingsPage() {
</h1> </h1>
</div> </div>
<div className="mt-8 flow-root"> <div className="border-t border-light-300 dark:border-dark-300">
<div className="-mx-4 -my-2 overflow-x-auto sm:-mx-6 lg:-mx-8"> <h2 className="mt-8 text-[14px] text-neutral-900 dark:text-dark-1000">
<div className="inline-block min-w-full py-2 align-middle sm:px-6 lg:px-8"> Delete workspace
<div className="overflow-hidden shadow ring-1 ring-black ring-opacity-5 sm:rounded-lg"></div> </h2>
</div> <p className="mb-8 mt-2 text-sm text-neutral-500 dark:text-dark-900">
</div> Once you delete your workspace, there is no going back. Please be
certain.
</p>
<Button variant="primary" onClick={() => openModal("DELETE_WORKSPACE")}>
Delete workspace
</Button>
</div> </div>
<Modal> <Modal>
{modalContentType === "NEW_WORKSPACE" && <NewWorkspaceForm />} {modalContentType === "DELETE_WORKSPACE" && (
<DeleteWorkspaceConfirmation />
)}
</Modal> </Modal>
</div> </div>
); );

View File

@@ -207,10 +207,25 @@ USING (
) )
); );
CREATE POLICY "Allow access to user's workspaces" CREATE POLICY "Allow viewing user's workspaces"
ON public.workspace ON public.workspace
AS PERMISSIVE AS PERMISSIVE
FOR ALL FOR SELECT
TO authenticated
USING (
id IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
OR
"createdBy" = auth.uid()
);
CREATE POLICY "Allow updating user's workspaces"
ON public.workspace
AS PERMISSIVE
FOR UPDATE
TO authenticated TO authenticated
USING ( USING (
id IN ( id IN (
@@ -220,6 +235,26 @@ USING (
) )
); );
CREATE POLICY "Allow deleting user's workspaces"
ON public.workspace
AS PERMISSIVE
FOR DELETE
TO authenticated
USING (
id IN (
SELECT "workspaceId"
FROM workspace_members
WHERE "userId" = auth.uid()
)
);
CREATE POLICY "Allow authenticated users to create workspaces"
ON public.workspace
AS PERMISSIVE
FOR INSERT
TO authenticated
USING (true);
CREATE POLICY "Allow access to user's own workspace membership" CREATE POLICY "Allow access to user's own workspace membership"
ON public.workspace_members ON public.workspace_members
AS PERMISSIVE AS PERMISSIVE