Merge pull request #13 from Nezumi-2711/feat/multi-tenancy

Featuer/ Implement multi tenancy
This commit is contained in:
2025-07-27 16:47:37 +07:00
committed by GitHub
15 changed files with 256 additions and 42 deletions
+3
View File
@@ -8,6 +8,7 @@
"@payloadcms/db-mongodb": "^3.36.0",
"@payloadcms/next": "^3.36.0",
"@payloadcms/payload-cloud": "^3.36.0",
"@payloadcms/plugin-multi-tenant": "3.36.0",
"@payloadcms/richtext-lexical": "^3.36.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-alert-dialog": "^1.1.11",
@@ -462,6 +463,8 @@
"@payloadcms/payload-cloud": ["@payloadcms/payload-cloud@3.36.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "^3.614.0", "@aws-sdk/client-s3": "^3.614.0", "@aws-sdk/credential-providers": "^3.614.0", "@aws-sdk/lib-storage": "^3.614.0", "@payloadcms/email-nodemailer": "3.36.0", "amazon-cognito-identity-js": "^6.1.2", "nodemailer": "6.9.16" }, "peerDependencies": { "payload": "3.36.0" } }, "sha512-3S8PVBosNdPxk5FnlphfeqCS9ECDkMK4KTaZgXdoNI184psDH9Mu+QKBmMX2sH4WXxNVqQ5r7BRLNtQQqDvHkw=="],
"@payloadcms/plugin-multi-tenant": ["@payloadcms/plugin-multi-tenant@3.36.0", "", { "peerDependencies": { "@payloadcms/ui": "3.36.0", "next": "^15.2.3", "payload": "3.36.0" } }, "sha512-geX5jHLG5GUF/NVhyySNg0vG9sWVfeEPSSFh1L+YL48YFxRusITKSGXIzBdgNYUcQntQFeyTrGNWhazH8H4zZQ=="],
"@payloadcms/richtext-lexical": ["@payloadcms/richtext-lexical@3.36.0", "", { "dependencies": { "@lexical/headless": "0.28.0", "@lexical/html": "0.28.0", "@lexical/link": "0.28.0", "@lexical/list": "0.28.0", "@lexical/mark": "0.28.0", "@lexical/react": "0.28.0", "@lexical/rich-text": "0.28.0", "@lexical/selection": "0.28.0", "@lexical/table": "0.28.0", "@lexical/utils": "0.28.0", "@payloadcms/translations": "3.36.0", "@payloadcms/ui": "3.36.0", "@types/uuid": "10.0.0", "acorn": "8.12.1", "bson-objectid": "2.0.4", "dequal": "2.0.3", "escape-html": "1.0.3", "jsox": "1.2.121", "lexical": "0.28.0", "mdast-util-from-markdown": "2.0.2", "mdast-util-mdx-jsx": "3.1.3", "micromark-extension-mdx-jsx": "3.0.1", "qs-esm": "7.0.2", "react-error-boundary": "4.1.2", "ts-essentials": "10.0.3", "uuid": "10.0.0" }, "peerDependencies": { "@faceless-ui/modal": "3.0.0-beta.2", "@faceless-ui/scroll-info": "2.0.0", "@payloadcms/next": "3.36.0", "payload": "3.36.0", "react": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020", "react-dom": "^19.0.0 || ^19.0.0-rc-65a56d0e-20241020" } }, "sha512-HiT4fE7vENAXeWt8jup8wEYxtvFzx4TjnPjyCssxwbX/96ROlLQheVAq35zPAEYQnFZZMCfmaobpW/lUYgewXQ=="],
"@payloadcms/translations": ["@payloadcms/translations@3.36.0", "", { "dependencies": { "date-fns": "4.1.0" } }, "sha512-+TNesB9N85OAm6Cwt2wnQcrwhMv9z8S7vQYSWMyB/nS3V9cWHpsKShCTh9bLoIN2MjGTZ5QLw2Wx6p5kuvNX0w=="],
+3 -1
View File
@@ -10,13 +10,15 @@
"lint": "next lint",
"generate:types": "payload generate:types",
"db:fresh": "payload migrate:fresh",
"db:seed": "bun run src/seed.ts"
"db:seed": "bun run src/seed.ts",
"db:reset": "bun run db:fresh && bun run db:seed"
},
"dependencies": {
"@hookform/resolvers": "^5.0.1",
"@payloadcms/db-mongodb": "^3.36.0",
"@payloadcms/next": "^3.36.0",
"@payloadcms/payload-cloud": "^3.36.0",
"@payloadcms/plugin-multi-tenant": "3.36.0",
"@payloadcms/richtext-lexical": "^3.36.0",
"@radix-ui/react-accordion": "^1.2.8",
"@radix-ui/react-alert-dialog": "^1.1.11",
@@ -4,6 +4,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server';
import { loadProductFilters } from '@/modules/products/search-params';
import { ProductListView } from '@/modules/products/ui/views/product-list-view';
import { DEFAULT_LIMIT } from '@/constants';
interface Props {
params: Promise<{
@@ -17,10 +18,11 @@ const Page = async ({ params, searchParams }: Props) => {
const queryClient = getQueryClient();
const filters = await loadProductFilters(searchParams);
void queryClient.prefetchQuery(
trpc.products.getMany.queryOptions({
category: subcategory,
void queryClient.prefetchInfiniteQuery(
trpc.products.getMany.infiniteQueryOptions({
...filters,
category: subcategory,
limit: DEFAULT_LIMIT,
})
);
+5 -3
View File
@@ -4,6 +4,7 @@ import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { getQueryClient, trpc } from '@/trpc/server';
import { loadProductFilters } from '@/modules/products/search-params';
import { ProductListView } from '@/modules/products/ui/views/product-list-view';
import { DEFAULT_LIMIT } from '@/constants';
interface Props {
params: Promise<{
@@ -17,10 +18,11 @@ const Page = async ({ params, searchParams }: Props) => {
const queryClient = getQueryClient();
const filters = await loadProductFilters(searchParams);
void queryClient.prefetchQuery(
trpc.products.getMany.queryOptions({
category,
void queryClient.prefetchInfiniteQuery(
trpc.products.getMany.infiniteQueryOptions({
...filters,
category,
limit: DEFAULT_LIMIT,
})
);
+28 -9
View File
@@ -1,12 +1,31 @@
'use client';
import type { SearchParams } from 'nuqs/server';
import { dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { useQuery } from "@tanstack/react-query";
import { getQueryClient, trpc } from '@/trpc/server';
import { loadProductFilters } from '@/modules/products/search-params';
import { ProductListView } from '@/modules/products/ui/views/product-list-view';
import { DEFAULT_LIMIT } from '@/constants';
import { useTRPC } from "@/trpc/client";
export default function Home() {
const trpc = useTRPC();
const { data } = useQuery(trpc.auth.session.queryOptions());
return <div>{JSON.stringify(data?.user, null, 2)}</div>;
interface Props {
searchParams: Promise<SearchParams>;
}
const Page = async ({ searchParams }: Props) => {
const filters = await loadProductFilters(searchParams);
const queryClient = getQueryClient();
void queryClient.prefetchInfiniteQuery(
trpc.products.getMany.infiniteQueryOptions({
...filters,
limit: DEFAULT_LIMIT,
})
);
return (
<HydrationBoundary state={dehydrate(queryClient)}>
<ProductListView />
</HydrationBoundary>
);
};
export default Page;
+6 -2
View File
@@ -1,5 +1,9 @@
import { TenantField as TenantField_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantSelector as TenantSelector_1d0591e3cf4f332c83a86da13a0de59a } from '@payloadcms/plugin-multi-tenant/client'
import { TenantSelectionProvider as TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62 } from '@payloadcms/plugin-multi-tenant/rsc'
export const importMap = {
"@payloadcms/plugin-multi-tenant/client#TenantField": TenantField_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/client#TenantSelector": TenantSelector_1d0591e3cf4f332c83a86da13a0de59a,
"@payloadcms/plugin-multi-tenant/rsc#TenantSelectionProvider": TenantSelectionProvider_d6d5f193a167989e2ee7d14202901e62
}
+50
View File
@@ -0,0 +1,50 @@
import { CollectionConfig } from "payload";
export const Tenants: CollectionConfig = {
slug: 'tenants',
admin: {
useAsTitle: 'slug',
},
fields: [
{
name: 'name',
type: 'text',
required: true,
label: 'Store Name',
admin: {
description: "This is the name of the store (e.g. Nezumi's Store)",
}
},
{
name: 'slug',
type: 'text',
index: true,
required: true,
unique: true,
admin: {
description: "This is the subdomain for the store (e.g. [slug].funroad.com)",
}
},
{
name: 'image',
type: 'upload',
relationTo: 'media',
},
{
name: 'stripeAccountId',
type: 'text',
required: true,
admin: {
readOnly: true,
}
},
{
name: 'stripeDetailsSubmitted',
type: 'checkbox',
admin: {
readOnly: true,
description: 'You cannot create products until you submit your Stripe details',
}
}
]
}
+35 -1
View File
@@ -1,4 +1,21 @@
import type { CollectionConfig } from 'payload'
import type { CollectionConfig } from 'payload';
import { tenantsArrayField } from '@payloadcms/plugin-multi-tenant/fields';
const defaultTenantArrayField = tenantsArrayField({
tenantsArrayFieldName: 'tenants',
tenantsCollectionSlug: 'tenants',
tenantsArrayTenantFieldName: 'tenant',
arrayFieldAccess: {
read: () => true,
create: () => true,
update: () => true,
},
tenantFieldAccess: {
read: () => true,
create: () => true,
update: () => true,
}
})
export const Users: CollectionConfig = {
slug: 'users',
@@ -12,6 +29,23 @@ export const Users: CollectionConfig = {
required: true,
unique: true,
type: 'text'
},
{
admin: {
position: 'sidebar',
},
name: 'roles',
type: 'select',
defaultValue: ['user'],
hasMany: true,
options: ['super-admin', 'user'],
},
{
...defaultTenantArrayField,
admin: {
...(defaultTenantArrayField?.admin || {}),
position: 'sidebar',
}
}
],
}
+15 -1
View File
@@ -34,13 +34,27 @@ export const authRouter = createTRPCRouter({
})
}
const tenant = await ctx.db.create({
collection: 'tenants',
data: {
name: input.username,
slug: input.username,
stripeAccountId: 'test',
}
})
await ctx.db.create({
collection: 'users',
data: {
email: input.email,
username: input.username,
password: input.password,
}
tenants: [
{
tenant: tenant.id,
},
],
},
})
const data = await ctx.db.login({
+4 -2
View File
@@ -7,7 +7,7 @@ import { cn } from "@/lib/utils";
import { registerSchema, RegisterSchema } from "@/modules/auth/schemas";
import { useTRPC } from "@/trpc/client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Poppins } from "next/font/google";
import Link from "next/link";
import { useRouter } from "next/navigation";
@@ -22,11 +22,13 @@ const poppins = Poppins({
export const SignUpView = () => {
const router = useRouter();
const trpc = useTRPC();
const queryClient = useQueryClient();
const register = useMutation(trpc.auth.register.mutationOptions({
onError: (error) => {
toast.error(error.message)
},
onSuccess: () => {
onSuccess: async () => {
await queryClient.invalidateQueries(trpc.auth.session.queryFilter())
router.push('/');
}
}));
@@ -1,14 +1,14 @@
'use client';
import { useRef, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import useDropdownPosition from './use-dropdown-position';
import { cn } from '@/lib/utils'
import { CategoriesGetManyOutput } from '@/modules/categories/type';
;
import SubCategoryMenu from './subcategory-menu';
import Link from 'next/link';
import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface CategoryDropdownProps {
category: CategoriesGetManyOutput[1];
@@ -23,8 +23,6 @@ const CategoryDropdown = ({
}: CategoryDropdownProps) => {
const [isOpen, setIsOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const { getDropdownPosition } = useDropdownPosition(dropdownRef);
const dropdownPosition = getDropdownPosition();
// TODO: Will improve in mobile screen
// const toggleDropdown = () => {
@@ -77,7 +75,6 @@ const CategoryDropdown = ({
<SubCategoryMenu
category={category}
isOpen={isOpen}
position={dropdownPosition}
/>
</div>
);
@@ -6,17 +6,9 @@ import { CategoriesGetManyOutput } from '@/modules/categories/type';
interface SubCategoryMenuProps {
category: CategoriesGetManyOutput[1];
isOpen: boolean;
position: {
top: number;
left: number;
};
}
const SubCategoryMenu = ({
category,
isOpen,
position,
}: SubCategoryMenuProps) => {
const SubCategoryMenu = ({ category, isOpen }: SubCategoryMenuProps) => {
if (
!isOpen ||
!category.subcategories ||
@@ -29,10 +21,10 @@ const SubCategoryMenu = ({
return (
<div
className="fixed z-100"
className="absolute z-100"
style={{
top: position?.top,
left: position?.left,
top: '100%',
left: 0,
}}
>
{/* Invisible bridge to maintain hover */}
+58
View File
@@ -72,6 +72,7 @@ export interface Config {
categories: Category;
products: Product;
tags: Tag;
tenants: Tenant;
'payload-locked-documents': PayloadLockedDocument;
'payload-preferences': PayloadPreference;
'payload-migrations': PayloadMigration;
@@ -87,6 +88,7 @@ export interface Config {
categories: CategoriesSelect<false> | CategoriesSelect<true>;
products: ProductsSelect<false> | ProductsSelect<true>;
tags: TagsSelect<false> | TagsSelect<true>;
tenants: TenantsSelect<false> | TenantsSelect<true>;
'payload-locked-documents': PayloadLockedDocumentsSelect<false> | PayloadLockedDocumentsSelect<true>;
'payload-preferences': PayloadPreferencesSelect<false> | PayloadPreferencesSelect<true>;
'payload-migrations': PayloadMigrationsSelect<false> | PayloadMigrationsSelect<true>;
@@ -130,6 +132,13 @@ export interface UserAuthOperations {
export interface User {
id: string;
username: string;
roles?: ('super-admin' | 'user')[] | null;
tenants?:
| {
tenant: string | Tenant;
id?: string | null;
}[]
| null;
updatedAt: string;
createdAt: string;
email: string;
@@ -141,6 +150,29 @@ export interface User {
lockUntil?: string | null;
password?: string | null;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tenants".
*/
export interface Tenant {
id: string;
/**
* This is the name of the store (e.g. Nezumi's Store)
*/
name: string;
/**
* This is the subdomain for the store (e.g. [slug].funroad.com)
*/
slug: string;
image?: (string | null) | Media;
stripeAccountId: string;
/**
* You cannot create products until you submit your Stripe details
*/
stripeDetailsSubmitted?: boolean | null;
updatedAt: string;
createdAt: string;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "media".
@@ -184,6 +216,7 @@ export interface Category {
*/
export interface Product {
id: string;
tenant?: (string | null) | Tenant;
name: string;
description?: string | null;
/**
@@ -234,6 +267,10 @@ export interface PayloadLockedDocument {
| ({
relationTo: 'tags';
value: string | Tag;
} | null)
| ({
relationTo: 'tenants';
value: string | Tenant;
} | null);
globalSlug?: string | null;
user: {
@@ -283,6 +320,13 @@ export interface PayloadMigration {
*/
export interface UsersSelect<T extends boolean = true> {
username?: T;
roles?: T;
tenants?:
| T
| {
tenant?: T;
id?: T;
};
updatedAt?: T;
createdAt?: T;
email?: T;
@@ -329,6 +373,7 @@ export interface CategoriesSelect<T extends boolean = true> {
* via the `definition` "products_select".
*/
export interface ProductsSelect<T extends boolean = true> {
tenant?: T;
name?: T;
description?: T;
price?: T;
@@ -349,6 +394,19 @@ export interface TagsSelect<T extends boolean = true> {
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "tenants_select".
*/
export interface TenantsSelect<T extends boolean = true> {
name?: T;
slug?: T;
image?: T;
stripeAccountId?: T;
stripeDetailsSubmitted?: T;
updatedAt?: T;
createdAt?: T;
}
/**
* This interface was referenced by `Config`'s JSON-Schema
* via the `definition` "payload-locked-documents_select".
+12 -1
View File
@@ -6,12 +6,14 @@ import path from 'path'
import { buildConfig } from 'payload'
import { fileURLToPath } from 'url'
import sharp from 'sharp'
import { multiTenantPlugin } from '@payloadcms/plugin-multi-tenant'
import { Users } from './collections/Users';
import { Media } from './collections/Media'
import { Categories } from './collections/Categories'
import { Products } from './collections/Products'
import { Tags } from './collections/Tags'
import { Tenants } from './collections/Tenants'
const filename = fileURLToPath(import.meta.url)
const dirname = path.dirname(filename)
@@ -24,7 +26,7 @@ export default buildConfig({
},
},
cookiePrefix: 'funroad',
collections: [Users, Media, Categories, Products, Tags],
collections: [Users, Media, Categories, Products, Tags, Tenants],
editor: lexicalEditor(),
secret: process.env.PAYLOAD_SECRET || '',
typescript: {
@@ -36,6 +38,15 @@ export default buildConfig({
sharp,
plugins: [
payloadCloudPlugin(),
multiTenantPlugin({
collections: {
products: {},
},
tenantsArrayField: {
includeDefaultField: false,
},
userHasAccessToAllTenants: (user) => Boolean(user?.roles?.includes('super-admin')),
})
// storage-adapter-placeholder
],
})
+24
View File
@@ -140,6 +140,30 @@ const categories = [
const seed = async () => {
const payload = await getPayload({ config });
const adminTenant = await payload.create({
collection: 'tenants',
data: {
name: 'admin',
slug: 'admin',
stripeAccountId: 'admin',
}
})
await payload.create({
collection: 'users',
data: {
email: 'admin@nezumi.pw',
password: 'demo',
roles: ['super-admin'],
username: 'admin',
tenants: [
{
tenant: adminTenant.id,
}
]
}
})
for (const category of categories) {
const { name, slug, color } = category;
const categoryDoc = await payload.create({