diff --git a/src/app/(dashboard)/customers/page.tsx b/src/app/(dashboard)/customers/page.tsx index f54db99..635d149 100644 --- a/src/app/(dashboard)/customers/page.tsx +++ b/src/app/(dashboard)/customers/page.tsx @@ -3,14 +3,25 @@ export const dynamic = "force-dynamic"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { listCustomers } from "@/lib/appwrite/customer-queries"; import { CustomersClient } from "@/components/customers/customers-client"; +import { checkLimit, PLAN_LIMITS } from "@/lib/plans"; export default async function CustomersPage() { const ctx = await requireTenant(); - const customers = await listCustomers(ctx.tenantId); + const [customers, limitResult] = await Promise.all([ + listCustomers(ctx.tenantId), + checkLimit(ctx.tenantId, ctx.settings?.plan, "customers"), + ]); + + const plan = ctx.settings?.plan ?? "free"; + const limit = PLAN_LIMITS[plan].customers; return (
- +
); } diff --git a/src/app/(dashboard)/dashboard-shell.tsx b/src/app/(dashboard)/dashboard-shell.tsx index f11b4b7..aa54db6 100644 --- a/src/app/(dashboard)/dashboard-shell.tsx +++ b/src/app/(dashboard)/dashboard-shell.tsx @@ -24,18 +24,22 @@ export type ShellCompany = { logoUrl?: string | null; }; +export type ShellRole = "owner" | "admin" | "member"; + export function DashboardShell({ user, company, children, initialPrefs, pendingMatchCount = 0, + role = "member", }: { user: ShellUser; company: ShellCompany; children: React.ReactNode; initialPrefs: ThemePrefs; pendingMatchCount?: number; + role?: ShellRole; }) { const [themeCustomizerOpen, setThemeCustomizerOpen] = React.useState(false); const { config } = useSidebarConfig(); @@ -63,6 +67,7 @@ export function DashboardShell({ collapsible={config.collapsible} side={config.side} pendingMatchCount={pendingMatchCount} + role={role} /> @@ -84,6 +89,7 @@ export function DashboardShell({ collapsible={config.collapsible} side={config.side} pendingMatchCount={pendingMatchCount} + role={role} /> )} diff --git a/src/app/(dashboard)/investors/page.tsx b/src/app/(dashboard)/investors/page.tsx index 8cfda4c..2138dcb 100644 --- a/src/app/(dashboard)/investors/page.tsx +++ b/src/app/(dashboard)/investors/page.tsx @@ -1,5 +1,6 @@ export const dynamic = "force-dynamic"; +import { redirect } from "next/navigation"; import { Query } from "node-appwrite"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; @@ -9,6 +10,7 @@ import { InvestorsClient } from "@/components/investors/investors-client"; export default async function InvestorsPage() { const ctx = await requireTenant(); + if (ctx.role === "member") redirect("/dashboard"); const { tablesDB } = createAdminClient(); const result = await tablesDB.listRows({ diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index b73d7d1..c019191 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -6,6 +6,7 @@ import { getLogoUrl } from "@/lib/appwrite/storage"; import { createAdminClient, createSessionClient, getCurrentUser } from "@/lib/appwrite/server"; import { DATABASE_ID, TABLES } from "@/lib/appwrite/schema"; import type { ThemePrefs } from "@/lib/appwrite/theme-prefs-actions"; +import { PLAN_LIMITS } from "@/lib/plans"; import { DashboardShell } from "./dashboard-shell"; export default async function DashboardLayout({ @@ -19,6 +20,13 @@ export default async function DashboardLayout({ const ctx = await getActiveContext(); if (!ctx) redirect("/onboarding"); + // Hard plan enforcement: non-owner members on free plan with more than 1 confirmed member + const plan = ctx.settings?.plan ?? "free"; + const memberLimit = PLAN_LIMITS[plan].teamMembers; + if (ctx.role !== "owner" && memberLimit !== Infinity && ctx.memberCount > memberLimit) { + redirect("/plan-limit"); + } + let pendingMatchCount = 0; try { const { tablesDB } = createAdminClient(); @@ -53,7 +61,7 @@ export default async function DashboardLayout({ }; return ( - + {children} ); diff --git a/src/app/(dashboard)/presentations/page.tsx b/src/app/(dashboard)/presentations/page.tsx index 10c5427..fd2759b 100644 --- a/src/app/(dashboard)/presentations/page.tsx +++ b/src/app/(dashboard)/presentations/page.tsx @@ -8,12 +8,13 @@ import { listProperties } from "@/lib/appwrite/property-queries"; import { DATABASE_ID, TABLES, type Presentation } from "@/lib/appwrite/schema"; import { createAdminClient } from "@/lib/appwrite/server"; import { PresentationsClient } from "@/components/presentations/presentations-client"; +import { checkLimit, PLAN_LIMITS } from "@/lib/plans"; export default async function PresentationsPage() { const ctx = await requireTenant(); const { tablesDB } = createAdminClient(); - const [customers, properties, presResult] = await Promise.all([ + const [customers, properties, presResult, limitResult] = await Promise.all([ listCustomers(ctx.tenantId), listProperties(ctx.tenantId), tablesDB.listRows({ @@ -25,12 +26,15 @@ export default async function PresentationsPage() { Query.limit(200), ], }), + checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"), ]).catch((e) => { console.error("[PresentationsPage] data fetch failed:", e); throw e; }); const presentations = JSON.parse(JSON.stringify(presResult.rows)) as Presentation[]; + const plan = ctx.settings?.plan ?? "free"; + const presLimit = PLAN_LIMITS[plan].presentations; return (
@@ -38,6 +42,8 @@ export default async function PresentationsPage() { initialPresentations={presentations} customers={customers} properties={properties} + isOwner={ctx.role === "owner"} + usageLimit={presLimit !== Infinity ? { current: limitResult.current, limit: presLimit } : undefined} />
); diff --git a/src/app/(dashboard)/properties/page.tsx b/src/app/(dashboard)/properties/page.tsx index e78b3d6..d249586 100644 --- a/src/app/(dashboard)/properties/page.tsx +++ b/src/app/(dashboard)/properties/page.tsx @@ -3,14 +3,28 @@ export const dynamic = "force-dynamic"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { listProperties } from "@/lib/appwrite/property-queries"; import { PropertiesClient } from "@/components/properties/properties-client"; +import { checkLimit, PLAN_LIMITS } from "@/lib/plans"; export default async function PropertiesPage() { const ctx = await requireTenant(); - const properties = await listProperties(ctx.tenantId); + const [properties, limitResult] = await Promise.all([ + listProperties(ctx.tenantId), + checkLimit(ctx.tenantId, ctx.settings?.plan, "properties"), + ]); + + const plan = ctx.settings?.plan ?? "free"; + const limit = PLAN_LIMITS[plan].properties; + + const imgLimit = PLAN_LIMITS[plan].propertyImages; return (
- +
); } diff --git a/src/app/(dashboard)/settings/billing/page.tsx b/src/app/(dashboard)/settings/billing/page.tsx index 4c766a3..6fa605e 100644 --- a/src/app/(dashboard)/settings/billing/page.tsx +++ b/src/app/(dashboard)/settings/billing/page.tsx @@ -25,6 +25,8 @@ export default async function BillingPage({ redirect("/onboarding"); } + if (ctx.role !== "owner") redirect("/dashboard"); + const params = await searchParams; const upgraded = params.upgraded === "1"; const downgraded = params.downgraded === "1"; diff --git a/src/app/(dashboard)/settings/members/page.tsx b/src/app/(dashboard)/settings/members/page.tsx index 8bc2038..fb6c496 100644 --- a/src/app/(dashboard)/settings/members/page.tsx +++ b/src/app/(dashboard)/settings/members/page.tsx @@ -23,6 +23,8 @@ export default async function MembersPage() { redirect("/onboarding"); } + if (ctx.role === "member") redirect("/dashboard"); + const canManage = ctx.role === "owner" || ctx.role === "admin"; const isOwner = ctx.role === "owner"; diff --git a/src/app/(dashboard)/settings/workspace/page.tsx b/src/app/(dashboard)/settings/workspace/page.tsx index eefc626..26ecf34 100644 --- a/src/app/(dashboard)/settings/workspace/page.tsx +++ b/src/app/(dashboard)/settings/workspace/page.tsx @@ -20,7 +20,9 @@ export default async function WorkspaceSettingsPage() { redirect("/onboarding"); } - const canEdit = ctx.role === "owner" || ctx.role === "admin"; + if (ctx.role === "member") redirect("/dashboard"); + + const canEdit = ctx.role === "owner"; const officeName = ctx.settings?.officeName ?? "Ofis"; return ( diff --git a/src/app/plan-limit/page.tsx b/src/app/plan-limit/page.tsx new file mode 100644 index 0000000..36def7c --- /dev/null +++ b/src/app/plan-limit/page.tsx @@ -0,0 +1,99 @@ +import { redirect } from "next/navigation"; +import Link from "next/link"; +import { getCurrentUser, createAdminClient } from "@/lib/appwrite/server"; +import { getActiveTenantId } from "@/lib/appwrite/tenant"; +import { DATABASE_ID, TABLES, type TenantSettings } from "@/lib/appwrite/schema"; +import { PLAN_LIMITS } from "@/lib/plans"; +import { Query } from "node-appwrite"; +import { SignOutForm } from "./sign-out-form"; + +export default async function PlanLimitPage() { + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + + const tenantId = await getActiveTenantId(); + if (!tenantId) redirect("/onboarding"); + + const { tablesDB, teams } = createAdminClient(); + + const [settingsResult, membershipsResult] = await Promise.all([ + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }), + teams.listMemberships(tenantId), + ]); + + const settings = settingsResult.rows[0] as unknown as TenantSettings | undefined; + const plan = settings?.plan ?? "free"; + const memberLimit = PLAN_LIMITS[plan].teamMembers; + + // If user is owner or plan allows them, redirect back to dashboard + const myMembership = membershipsResult.memberships.find((m) => m.userId === user.$id); + const isOwner = myMembership?.roles?.includes("owner"); + const confirmedCount = membershipsResult.memberships.filter((m) => m.confirm).length; + + if (isOwner || memberLimit === Infinity || confirmedCount <= memberLimit) { + redirect("/dashboard"); + } + + // Find the owner to show their contact + const ownerMembership = membershipsResult.memberships.find((m) => m.roles?.includes("owner")); + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

Erişim Kısıtlandı

+

+ {settings?.officeName ?? "Bu çalışma alanı"} ücretsiz plana geçti +

+
+ + {/* Body */} +
+

+ Ücretsiz planda yalnızca 1 kullanıcı sisteme erişebilir. + Bu çalışma alanındaki erişiminiz geçici olarak askıya alınmıştır. +

+ +
+ Yöneticinizden çalışma alanını Pro plana yükseltmesini isteyin ya da + hesabınızı çalışma alanından çıkarmasını talep edin. +
+ + {ownerMembership?.userEmail && ( +
+

Çalışma alanı yöneticisi

+

{ownerMembership.userName ?? ownerMembership.userEmail}

+ + {ownerMembership.userEmail} + +
+ )} +
+ + {/* Footer */} +
+ + + Plan karşılaştır + +
+
+
+ ); +} diff --git a/src/app/plan-limit/sign-out-form.tsx b/src/app/plan-limit/sign-out-form.tsx new file mode 100644 index 0000000..4b17a3f --- /dev/null +++ b/src/app/plan-limit/sign-out-form.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { useTransition } from "react"; +import { signOutAction } from "@/lib/appwrite/auth-actions"; + +export function SignOutForm() { + const [isPending, startTransition] = useTransition(); + + function handleSignOut() { + startTransition(async () => { + await signOutAction(); + }); + } + + return ( + + ); +} diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 19790c2..5a58cc0 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -30,9 +30,26 @@ import { SidebarMenuItem, } from "@/components/ui/sidebar"; -import type { ShellCompany, ShellUser } from "@/app/(dashboard)/dashboard-shell"; +import type { Icon } from "@/lib/icons"; +import type { ShellCompany, ShellRole, ShellUser } from "@/app/(dashboard)/dashboard-shell"; -const navGroups = [ +type NavItem = { + title: string; + url: string; + icon?: Icon; + items?: { title: string; url: string; badge?: number }[]; + minRole?: ShellRole; +}; + +type NavGroup = { label: string; items: NavItem[] }; + +const ROLE_RANK: Record = { owner: 3, admin: 2, member: 1 }; +function hasAccess(minRole: ShellRole | undefined, userRole: ShellRole): boolean { + if (!minRole) return true; + return ROLE_RANK[userRole] >= ROLE_RANK[minRole]; +} + +const navGroups: NavGroup[] = [ { label: "Genel", items: [ @@ -70,6 +87,7 @@ const navGroups = [ title: "Yatırımcılar", url: "/investors", icon: Wallet, + minRole: "admin" as ShellRole, }, ], }, @@ -100,6 +118,7 @@ const navGroups = [ title: "Çalışma Alanı", url: "/settings/workspace", icon: GearSix, + minRole: "admin" as ShellRole, items: [ { title: "Ofis Bilgileri", url: "/settings/workspace" }, { title: "Ekip Üyeleri", url: "/settings/members" }, @@ -114,6 +133,7 @@ const navGroups = [ title: "Plan & Faturalama", url: "/settings/billing", icon: CreditCard, + minRole: "owner" as ShellRole, }, ], }, @@ -123,24 +143,29 @@ export function AppSidebar({ user, company, pendingMatchCount = 0, + role = "member", ...props }: React.ComponentProps & { user: ShellUser; company: ShellCompany; pendingMatchCount?: number; + role?: ShellRole; }) { - // Inject badge into the Eşleşmeler sub-item - const groups = navGroups.map((group) => ({ - ...group, - items: group.items.map((item) => ({ - ...item, - items: item.items?.map((sub) => - sub.url === "/customers/matches" && pendingMatchCount > 0 - ? { ...sub, badge: pendingMatchCount } - : sub, - ), - })), - })); + const groups = navGroups + .map((group) => ({ + ...group, + items: group.items + .filter((item) => hasAccess(item.minRole, role)) + .map((item) => ({ + ...item, + items: item.items?.map((sub) => + sub.url === "/customers/matches" && pendingMatchCount > 0 + ? { ...sub, badge: pendingMatchCount } + : sub, + ), + })), + })) + .filter((group) => group.items.length > 0); return ( @@ -174,7 +199,11 @@ export function AppSidebar({ {groups.map((group) => ( - + rest)} + /> ))} diff --git a/src/components/customers/customers-client.tsx b/src/components/customers/customers-client.tsx index 5bbb0c3..e1ad81a 100644 --- a/src/components/customers/customers-client.tsx +++ b/src/components/customers/customers-client.tsx @@ -18,6 +18,7 @@ import { deleteCustomerAction } from "@/lib/appwrite/customer-actions"; import { CustomerFormSheet } from "./customer-form-sheet"; import { CustomersPipeline } from "./customers-pipeline"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; +import { UsageBadge } from "@/components/ui/usage-badge"; import type { Customer, CustomerType, CustomerStage } from "@/lib/appwrite/schema"; import { CUSTOMER_TYPE_LABELS, @@ -29,9 +30,11 @@ type ViewMode = "list" | "pipeline"; interface CustomersClientProps { initialCustomers: Customer[]; + isOwner?: boolean; + usageLimit?: { current: number; limit: number }; } -export function CustomersClient({ initialCustomers }: CustomersClientProps) { +export function CustomersClient({ initialCustomers, isOwner, usageLimit }: CustomersClientProps) { const router = useRouter(); const [customers, setCustomers] = useState(initialCustomers); const [sheetOpen, setSheetOpen] = useState(false); @@ -79,7 +82,10 @@ export function CustomersClient({ initialCustomers }: CustomersClientProps) { {/* Header */}

Müşteriler

-
+
+ {isOwner && usageLimit && ( + + )} {/* View toggle */}
+
+ {isOwner && usageLimit && ( + + )} + +
diff --git a/src/components/properties/properties-client.tsx b/src/components/properties/properties-client.tsx index 9ab3ac4..8c08ecc 100644 --- a/src/components/properties/properties-client.tsx +++ b/src/components/properties/properties-client.tsx @@ -20,6 +20,7 @@ import { ImageLightbox } from "./image-lightbox"; import { DeleteConfirmDialog } from "@/components/ui/delete-confirm-dialog"; import { PropertiesMapView } from "@/components/map/properties-map-view"; import { getPropertyImagePreviewUrl, parseImageIds } from "@/lib/appwrite/storage-utils"; +import { UsageBadge } from "@/components/ui/usage-badge"; import type { Property, PropertyStatus } from "@/lib/appwrite/schema"; import { PROPERTY_STATUS_LABELS, @@ -29,11 +30,14 @@ import { interface PropertiesClientProps { initialProperties: Property[]; + isOwner?: boolean; + usageLimit?: { current: number; limit: number }; + maxImagesPerProperty?: number; } type ViewMode = "list" | "gallery" | "map"; -export function PropertiesClient({ initialProperties }: PropertiesClientProps) { +export function PropertiesClient({ initialProperties, isOwner, usageLimit, maxImagesPerProperty }: PropertiesClientProps) { const router = useRouter(); const [properties, setProperties] = useState(initialProperties); const [sheetOpen, setSheetOpen] = useState(false); @@ -125,7 +129,10 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) {

)}
-
+
+ {isOwner && usageLimit && ( + + )} {/* View toggle */}
{([ @@ -397,6 +404,8 @@ export function PropertiesClient({ initialProperties }: PropertiesClientProps) { onOpenChange={setSheetOpen} property={editing} onSuccess={() => router.refresh()} + maxImages={maxImagesPerProperty} + isOwner={isOwner} /> void; property?: Property | null; onSuccess?: () => void; + maxImages?: number; + isOwner?: boolean; } -export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: PropertyFormSheetProps) { +export function PropertyFormSheet({ open, onOpenChange, property, onSuccess, maxImages, isOwner }: PropertyFormSheetProps) { const isMobile = useIsMobile(); const action = property ? updatePropertyAction.bind(null, property.$id) : createPropertyAction; @@ -207,7 +209,7 @@ export function PropertyFormSheet({ open, onOpenChange, property, onSuccess }: P
- +
), diff --git a/src/components/properties/property-image-uploader.tsx b/src/components/properties/property-image-uploader.tsx index 1ebfa77..3b7b976 100644 --- a/src/components/properties/property-image-uploader.tsx +++ b/src/components/properties/property-image-uploader.tsx @@ -15,9 +15,11 @@ interface PropertyImageUploaderProps { name: string; initialImageIds?: string[]; onChangeIds?: (ids: string[]) => void; + maxImages?: number; + isOwner?: boolean; } -export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds }: PropertyImageUploaderProps) { +export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds, maxImages, isOwner }: PropertyImageUploaderProps) { const [imageIds, setImageIds] = useState(initialImageIds); const [queue, setQueue] = useState([]); const [dragging, setDragging] = useState(false); @@ -40,6 +42,14 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds toast.error(`${file.name}: 50 MB sınırını aşıyor.`); return; } + if (maxImages !== undefined && imageIds.length >= maxImages) { + toast.error( + isOwner + ? `Ücretsiz planda ilan başına en fazla ${maxImages} fotoğraf ekleyebilirsiniz. Pro'ya geçerek limitsiz yükleyin.` + : "Bu çalışma alanı fotoğraf limitine ulaştı. Yöneticinizle iletişime geçin.", + ); + return; + } const uid = crypto.randomUUID(); setQueue((q) => [...q, { uid, name: file.name, progress: 0, phase: "compressing" }]); @@ -94,7 +104,7 @@ export function PropertyImageUploader({ name, initialImageIds = [], onChangeIds xhr.open("POST", "/api/properties/images"); xhr.send(fd); - }, []); // eslint-disable-line react-hooks/exhaustive-deps + }, [imageIds, maxImages]); // eslint-disable-line react-hooks/exhaustive-deps const handleFiles = useCallback((files: FileList | null) => { if (!files) return; diff --git a/src/components/ui/usage-badge.tsx b/src/components/ui/usage-badge.tsx new file mode 100644 index 0000000..8e78e77 --- /dev/null +++ b/src/components/ui/usage-badge.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { Crown } from "@/lib/icons"; +import type { LimitKey } from "@/lib/plans"; + +interface UsageBadgeProps { + current: number; + limit: number; + label: string; + limitKey?: LimitKey; +} + +export function UsageBadge({ current, limit, label }: UsageBadgeProps) { + if (limit === Infinity || limit <= 0) return null; + + const pct = current / limit; + const isWarning = pct >= 0.8; + const isFull = current >= limit; + + return ( + + {isFull && } + {current}/{limit} {label} + + ); +} + +interface UpgradeBannerProps { + message: string; +} + +export function UpgradeBanner({ message }: UpgradeBannerProps) { + return ( +
+ + {message} + + Pro'ya Geç + +
+ ); +} diff --git a/src/lib/appwrite/active-context.ts b/src/lib/appwrite/active-context.ts index 156a08d..14c16da 100644 --- a/src/lib/appwrite/active-context.ts +++ b/src/lib/appwrite/active-context.ts @@ -8,10 +8,18 @@ import { DATABASE_ID, TABLES, type TenantSettings } from "./schema"; import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; import { getActiveTenantId } from "./tenant"; +function pickHighestRole(roles: string[]): "owner" | "admin" | "member" { + if (roles.includes("owner")) return "owner"; + if (roles.includes("admin")) return "admin"; + return "member"; +} + export type ActiveContext = { user: { id: string; name: string; email: string }; tenantId: string; settings: TenantSettings | null; + role: "owner" | "admin" | "member"; + memberCount: number; }; async function setActiveTenantCookie(tenantId: string) { @@ -72,13 +80,22 @@ export async function getActiveContext(): Promise { if (!tenantId) return null; let settings: TenantSettings | null = null; + let role: "owner" | "admin" | "member" = "member"; + let memberCount = 1; + try { - const result = await tablesDB.listRows({ - databaseId: DATABASE_ID, - tableId: TABLES.tenantSettings, - queries: [Query.equal("tenantId", tenantId), Query.limit(1)], - }); - settings = (result.rows[0] as unknown as TenantSettings) ?? null; + const [settingsResult, membershipsResult] = await Promise.all([ + tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.tenantSettings, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }), + adminTeams.listMemberships(tenantId), + ]); + settings = (settingsResult.rows[0] as unknown as TenantSettings) ?? null; + const myMembership = membershipsResult.memberships.find((m) => m.userId === user.$id); + if (myMembership) role = pickHighestRole(myMembership.roles); + memberCount = membershipsResult.memberships.filter((m) => m.confirm).length; } catch { settings = null; } @@ -87,5 +104,7 @@ export async function getActiveContext(): Promise { user: { id: user.$id, name: user.name, email: user.email }, tenantId, settings, + role, + memberCount, }; } diff --git a/src/lib/appwrite/customer-actions.ts b/src/lib/appwrite/customer-actions.ts index cc08c31..9745a48 100644 --- a/src/lib/appwrite/customer-actions.ts +++ b/src/lib/appwrite/customer-actions.ts @@ -7,6 +7,7 @@ import { customerSchema } from "@/lib/validation/customers"; import { DATABASE_ID, TABLES, type CustomerStage } from "./schema"; import { createAdminClient } from "./server"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { checkLimit, limitErrorMessage } from "@/lib/plans"; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record }; @@ -21,6 +22,11 @@ export async function createCustomerAction( return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; } + const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "customers"); + if (!limitCheck.allowed) { + return { ok: false, error: limitErrorMessage("customers", limitCheck.limit, ctx.role) }; + } + const { tablesDB } = createAdminClient(); const data = parsed.data; diff --git a/src/lib/appwrite/logo-actions.ts b/src/lib/appwrite/logo-actions.ts index 5c96a7f..113cfcc 100644 --- a/src/lib/appwrite/logo-actions.ts +++ b/src/lib/appwrite/logo-actions.ts @@ -36,7 +36,7 @@ export async function uploadLogoAction( let ctx; try { ctx = await requireTenant(); - requireRole(ctx, ["owner", "admin"]); + requireRole(ctx, ["owner"]); } catch { return { ok: false, error: "Logo yüklemek için yönetici yetkisi gerekli." }; } @@ -121,7 +121,7 @@ export async function removeLogoAction(): Promise { let ctx; try { ctx = await requireTenant(); - requireRole(ctx, ["owner", "admin"]); + requireRole(ctx, ["owner"]); } catch { return { ok: false, error: "Logo silmek için yönetici yetkisi gerekli." }; } diff --git a/src/lib/appwrite/presentation-actions.ts b/src/lib/appwrite/presentation-actions.ts index 3330038..196bfaa 100644 --- a/src/lib/appwrite/presentation-actions.ts +++ b/src/lib/appwrite/presentation-actions.ts @@ -8,6 +8,7 @@ import { presentationSchema } from "@/lib/validation/presentations"; import { DATABASE_ID, TABLES } from "./schema"; import { createAdminClient } from "./server"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; +import { checkLimit, limitErrorMessage } from "@/lib/plans"; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record; id?: string }; @@ -26,6 +27,11 @@ export async function createPresentationAction( return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; } + const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "presentations"); + if (!limitCheck.allowed) { + return { ok: false, error: limitErrorMessage("presentations", limitCheck.limit, ctx.role) }; + } + const { tablesDB } = createAdminClient(); const data = parsed.data; const id = ID.unique(); diff --git a/src/lib/appwrite/property-actions.ts b/src/lib/appwrite/property-actions.ts index b6daa94..e648603 100644 --- a/src/lib/appwrite/property-actions.ts +++ b/src/lib/appwrite/property-actions.ts @@ -8,6 +8,7 @@ import { DATABASE_ID, TABLES, type Property } from "./schema"; import { createAdminClient } from "./server"; import { requireTenant } from "@/lib/appwrite/tenant-guard"; import { matchPropertyToSearches } from "./matching"; +import { checkLimit, limitErrorMessage } from "@/lib/plans"; type ActionState = { ok: boolean; error?: string; fieldErrors?: Record }; @@ -22,6 +23,11 @@ export async function createPropertyAction( return { ok: false, fieldErrors: parsed.error.flatten().fieldErrors }; } + const limitCheck = await checkLimit(ctx.tenantId, ctx.settings?.plan, "properties"); + if (!limitCheck.allowed) { + return { ok: false, error: limitErrorMessage("properties", limitCheck.limit, ctx.role) }; + } + const { tablesDB } = createAdminClient(); const id = ID.unique(); const data = parsed.data; diff --git a/src/lib/appwrite/team-actions.ts b/src/lib/appwrite/team-actions.ts index 7c75e68..a98d19c 100644 --- a/src/lib/appwrite/team-actions.ts +++ b/src/lib/appwrite/team-actions.ts @@ -10,6 +10,7 @@ import { createAdminClient, createSessionClient } from "./server"; import { requireRole, requireTenant } from "./tenant-guard"; import type { InviteState, MemberActionState } from "./team-types"; import { ACTIVE_TENANT_COOKIE } from "./tenant-types"; +import { checkLimit, limitErrorMessage } from "@/lib/plans"; const APP_URL = process.env.APP_URL ?? "http://localhost:3000"; const INVITE_TTL_DAYS = 7; @@ -66,6 +67,11 @@ export async function inviteMemberAction( return { ok: false, error: "Kendinizi davet edemezsiniz." }; } + const memberLimit = await checkLimit(ctx.tenantId, ctx.settings?.plan, "teamMembers"); + if (!memberLimit.allowed) { + return { ok: false, error: limitErrorMessage("teamMembers", memberLimit.limit, ctx.role) }; + } + const admin = createAdminClient(); // 1. Kullanıcı zaten Appwrite'ta var mı? diff --git a/src/lib/appwrite/tenant-guard.ts b/src/lib/appwrite/tenant-guard.ts index fc0a992..7f519fb 100644 --- a/src/lib/appwrite/tenant-guard.ts +++ b/src/lib/appwrite/tenant-guard.ts @@ -15,6 +15,7 @@ export type TenantContext = { tenantId: string; role: TenantRole; settings: TenantSettings | null; + memberCount: number; }; function pickHighestRole(roles: string[]): TenantRole | null { @@ -124,6 +125,7 @@ export async function requireTenant(): Promise { if (!membership) throw new Error("NOT_A_MEMBER"); const role = pickHighestRole(membership.roles) ?? "member"; + const memberCount = memberships.memberships.filter((m) => m.confirm).length; let settings: TenantSettings | null = null; try { @@ -137,6 +139,7 @@ export async function requireTenant(): Promise { tenantId, role, settings, + memberCount, }; } diff --git a/src/lib/appwrite/workspace-actions.ts b/src/lib/appwrite/workspace-actions.ts index af68f65..e0a02ab 100644 --- a/src/lib/appwrite/workspace-actions.ts +++ b/src/lib/appwrite/workspace-actions.ts @@ -42,7 +42,7 @@ export async function updateWorkspaceSettingsAction( let ctx; try { ctx = await requireTenant(); - requireRole(ctx, ["owner", "admin"]); + requireRole(ctx, ["owner"]); } catch { return { ok: false, error: "Düzenleme yetkiniz yok." }; } diff --git a/src/lib/plans.ts b/src/lib/plans.ts new file mode 100644 index 0000000..f872636 --- /dev/null +++ b/src/lib/plans.ts @@ -0,0 +1,88 @@ +import "server-only"; + +import { Query } from "node-appwrite"; + +import { createAdminClient } from "./appwrite/server"; +import { DATABASE_ID, TABLES, type TenantPlan } from "./appwrite/schema"; + +export const PLAN_LIMITS = { + free: { + properties: 15, + customers: 25, + presentations: 5, + teamMembers: 1, + propertyImages: 5, + }, + pro: { + properties: Infinity, + customers: Infinity, + presentations: Infinity, + teamMembers: Infinity, + propertyImages: Infinity, + }, +} as const; + +export type LimitKey = keyof typeof PLAN_LIMITS.free; + +export type LimitResult = { allowed: boolean; current: number; limit: number }; + +export async function checkLimit( + tenantId: string, + plan: TenantPlan | undefined | null, + key: LimitKey, +): Promise { + const planKey: TenantPlan = plan ?? "free"; + const limit = PLAN_LIMITS[planKey][key]; + + if (limit === Infinity) return { allowed: true, current: 0, limit: Infinity }; + + const { tablesDB, teams } = createAdminClient(); + + let current = 0; + + if (key === "properties") { + const r = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.properties, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + current = r.total; + } else if (key === "customers") { + const r = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.customers, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + current = r.total; + } else if (key === "presentations") { + const r = await tablesDB.listRows({ + databaseId: DATABASE_ID, + tableId: TABLES.presentations, + queries: [Query.equal("tenantId", tenantId), Query.limit(1)], + }); + current = r.total; + } else if (key === "teamMembers") { + const r = await teams.listMemberships(tenantId); + current = r.memberships.filter((m) => m.confirm).length; + } + + return { allowed: current < limit, current, limit }; +} + +export function limitLabel(key: LimitKey): string { + const labels: Record = { + properties: "ilan", + customers: "müşteri", + presentations: "sunum", + teamMembers: "ekip üyesi", + propertyImages: "fotoğraf", + }; + return labels[key]; +} + +export function limitErrorMessage(key: LimitKey, limit: number, role: "owner" | "admin" | "member"): string { + if (role === "owner") { + return `Ücretsiz planda en fazla ${limit} ${limitLabel(key)} ekleyebilirsiniz. Pro'ya geçerek limitsiz kullanın.`; + } + return `Bu çalışma alanı ${limitLabel(key)} limitine ulaştı. Yöneticinizle iletişime geçin.`; +}