feat: set workspace url when creating a new workspace (#172)

* feat: add migration to remove not null constraint from referenceId on subscriptions

* feat(cloud): write worspace slug availability checks to db

* feat: add migration to add cascade set null to referenceId on subscription

* feat: update workspace and subscripton schema

* feat: add createWorkspaceSlugCheck repo func

* feat: set workspace url on creation

* chore: update translations

* refactor: various tweaks and fixes

* feat: add pro bolt to pricing notice

* feat: toggle pro subscription

* fix: hide pro subscription notice for self hosters

* feat: add pro upgrade message on members page

* feat: hide pro upgrade notice on small viewports

* feat: update workspace url placeholder
This commit is contained in:
Henry
2025-09-11 20:32:02 +01:00
committed by GitHub
parent 4f7fa1a228
commit 2f88366418
31 changed files with 8942 additions and 385 deletions

View File

@@ -140,6 +140,12 @@ export const workspaceRouter = createTRPCRouter({
.input(
z.object({
name: z.string().min(1),
slug: z
.string()
.min(3)
.max(24)
.regex(/^(?![-]+$)[a-zA-Z0-9-]+$/)
.optional(),
}),
)
.output(z.custom<Awaited<ReturnType<typeof workspaceRepo.create>>>())
@@ -153,12 +159,43 @@ export const workspaceRouter = createTRPCRouter({
code: "UNAUTHORIZED",
});
// Check if slug is provided in cloud environment
if (input.slug && env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
throw new TRPCError({
message: "Custom URLs are only available for Pro workspaces",
code: "BAD_REQUEST",
});
}
const workspacePublicId = generateUID();
const workspaceSlug = input.slug ?? workspacePublicId;
if (input.slug) {
const reservedOrPremiumWorkspaceSlug =
await workspaceSlugRepo.getWorkspaceSlug(ctx.db, input.slug);
const isWorkspaceSlugAvailable =
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, input.slug);
if (reservedOrPremiumWorkspaceSlug) {
throw new TRPCError({
message: `Workspace slug '${input.slug}' is reserved or premium`,
code: "BAD_REQUEST",
});
}
if (!isWorkspaceSlugAvailable) {
throw new TRPCError({
message: `Workspace slug '${input.slug}' is already taken`,
code: "BAD_REQUEST",
});
}
}
const result = await workspaceRepo.create(ctx.db, {
publicId: workspacePublicId,
name: input.name,
slug: workspacePublicId,
slug: workspaceSlug,
createdBy: userId,
createdByEmail: userEmail,
});
@@ -334,6 +371,14 @@ export const workspaceRouter = createTRPCRouter({
}),
)
.query(async ({ ctx, input }) => {
const userId = ctx.user?.id;
if (!userId)
throw new TRPCError({
message: `User not authenticated`,
code: "UNAUTHORIZED",
});
const slug = input.workspaceSlug.toLowerCase();
// check slug is not reserved
const workspaceSlug = await workspaceSlugRepo.getWorkspaceSlug(
@@ -345,6 +390,19 @@ export const workspaceRouter = createTRPCRouter({
const isWorkspaceSlugAvailable =
await workspaceRepo.isWorkspaceSlugAvailable(ctx.db, slug);
const isAvailable =
isWorkspaceSlugAvailable && workspaceSlug?.type !== "reserved";
const isReserved = workspaceSlug?.type === "reserved";
if (env("NEXT_PUBLIC_KAN_ENV") === "cloud") {
await workspaceSlugRepo.createWorkspaceSlugCheck(ctx.db, {
slug,
userId,
available: isAvailable,
reserved: isReserved,
});
}
return {
isAvailable:
isWorkspaceSlugAvailable && workspaceSlug?.type !== "reserved",

View File

@@ -0,0 +1 @@
ALTER TABLE "subscription" ALTER COLUMN "referenceId" DROP NOT NULL;

View File

@@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS "workspace_slug_checks" (
"id" bigserial PRIMARY KEY NOT NULL,
"slug" varchar(255) NOT NULL,
"available" boolean NOT NULL,
"reserved" boolean NOT NULL,
"workspaceId" bigint,
"createdBy" uuid,
"createdAt" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "workspace_slug_checks" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_slug_checks" ADD CONSTRAINT "workspace_slug_checks_workspaceId_workspace_id_fk" FOREIGN KEY ("workspaceId") REFERENCES "public"."workspace"("id") ON DELETE no action ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "workspace_slug_checks" ADD CONSTRAINT "workspace_slug_checks_createdBy_user_id_fk" FOREIGN KEY ("createdBy") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

View File

@@ -0,0 +1,7 @@
ALTER TABLE "subscription" DROP CONSTRAINT "subscription_referenceId_workspace_publicId_fk";
--> statement-breakpoint
DO $$ BEGIN
ALTER TABLE "subscription" ADD CONSTRAINT "subscription_referenceId_workspace_publicId_fk" FOREIGN KEY ("referenceId") REFERENCES "public"."workspace"("publicId") ON DELETE set null ON UPDATE no action;
EXCEPTION
WHEN duplicate_object THEN null;
END $$;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -78,6 +78,27 @@
"when": 1757271312974,
"tag": "20250907185512_AddUnlimitedSeatsToSubscription",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1757533858687,
"tag": "20250910195058_RemoveNotNullConstraintFromReferenceIdOnSubsriptions",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1757534656098,
"tag": "20250910200416_AddWorkspaceSlugChecks",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1757535838766,
"tag": "20250910202358_AddCascadeSetNullToReferenceIdOnSubsriptions",
"breakpoints": true
}
]
}

View File

@@ -1,7 +1,7 @@
import { eq } from "drizzle-orm";
import type { dbClient } from "@kan/db/client";
import { slugs } from "@kan/db/schema";
import { slugChecks, slugs } from "@kan/db/schema";
export const getWorkspaceSlug = (db: dbClient, slug: string) => {
return db.query.slugs.findFirst({
@@ -12,3 +12,20 @@ export const getWorkspaceSlug = (db: dbClient, slug: string) => {
where: eq(slugs.slug, slug),
});
};
export const createWorkspaceSlugCheck = (
db: dbClient,
input: {
slug: string;
userId: string;
available: boolean;
reserved: boolean;
},
) => {
return db.insert(slugChecks).values({
slug: input.slug,
available: input.available,
reserved: input.reserved,
createdBy: input.userId,
});
};

View File

@@ -13,9 +13,10 @@ import { workspaces } from "./workspaces";
export const subscription = pgTable("subscription", {
id: bigserial("id", { mode: "number" }).primaryKey(),
plan: varchar("plan", { length: 255 }).notNull(),
referenceId: varchar("referenceId", { length: 12 })
.notNull()
.references(() => workspaces.publicId),
referenceId: varchar("referenceId", { length: 12 }).references(
() => workspaces.publicId,
{ onDelete: "set null" },
),
stripeCustomerId: varchar("stripeCustomerId", { length: 255 }),
stripeSubscriptionId: varchar("stripeSubscriptionId", { length: 255 }),
status: varchar("status", { length: 255 }).notNull(),

View File

@@ -2,6 +2,7 @@ import { relations } from "drizzle-orm";
import {
bigint,
bigserial,
boolean,
pgEnum,
pgTable,
text,
@@ -103,3 +104,17 @@ export const slugs = pgTable("workspace_slugs", {
slug: varchar("slug", { length: 255 }).notNull().unique(),
type: slugTypeEnum("type").notNull(),
});
export const slugChecks = pgTable("workspace_slug_checks", {
id: bigserial("id", { mode: "number" }).primaryKey(),
slug: varchar("slug", { length: 255 }).notNull(),
available: boolean("available").notNull(),
reserved: boolean("reserved").notNull(),
workspaceId: bigint("workspaceId", { mode: "number" }).references(
() => workspaces.id,
),
createdBy: uuid("createdBy").references(() => users.id, {
onDelete: "set null",
}),
createdAt: timestamp("createdAt").defaultNow().notNull(),
}).enableRLS();

View File

@@ -14,7 +14,7 @@ export interface Subscription {
unlimitedSeats: boolean;
periodStart: Date | null;
periodEnd: Date | null;
referenceId: string;
referenceId: string | null;
stripeSubscriptionId: string | null;
stripeCustomerId: string | null;
createdAt: Date;