From 64215f07e6be219a04cde39bf35c50549fef7a6b Mon Sep 17 00:00:00 2001 From: Admin Date: Thu, 30 Apr 2026 18:15:40 +0330 Subject: [PATCH] improvement: newly created organization's subscriptions flow improved. --- .../migrations/20260430134958/migration.sql | 5 + .../migration.sql | 3 + backend/prisma/schema.prisma | 4 +- backend/src/modules/auth/auth.service.ts | 80 +++++++++------ backend/src/modules/staff/staff.service.ts | 17 +++- frontend/src/app/(dashboard)/billing/page.tsx | 17 +++- .../src/app/(dashboard)/patients/page.tsx | 23 ++++- .../app/(dashboard)/settings/account/page.tsx | 2 +- .../settings/organizations/page.tsx | 2 +- .../settings/subscriptions/page.tsx | 77 +++++++++++++-- frontend/src/app/(dashboard)/staff/page.tsx | 99 +++++++++++-------- frontend/src/app/(dashboard)/today/page.tsx | 21 ++++ .../ui/dashboard/DashboardAccountMenu.tsx | 1 + frontend/src/shared/permissions.ts | 2 - frontend/src/types/subscription.ts | 3 +- 15 files changed, 264 insertions(+), 92 deletions(-) create mode 100644 backend/prisma/migrations/20260430134958/migration.sql create mode 100644 backend/prisma/migrations/20260430135000_make_organization_plan_optional/migration.sql diff --git a/backend/prisma/migrations/20260430134958/migration.sql b/backend/prisma/migrations/20260430134958/migration.sql new file mode 100644 index 0000000..69f3af6 --- /dev/null +++ b/backend/prisma/migrations/20260430134958/migration.sql @@ -0,0 +1,5 @@ +-- DropForeignKey +ALTER TABLE "organizations" DROP CONSTRAINT "organizations_planId_fkey"; + +-- AddForeignKey +ALTER TABLE "organizations" ADD CONSTRAINT "organizations_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/backend/prisma/migrations/20260430135000_make_organization_plan_optional/migration.sql b/backend/prisma/migrations/20260430135000_make_organization_plan_optional/migration.sql new file mode 100644 index 0000000..de88f2d --- /dev/null +++ b/backend/prisma/migrations/20260430135000_make_organization_plan_optional/migration.sql @@ -0,0 +1,3 @@ +-- Allow organizations without an active subscription plan. +ALTER TABLE "organizations" +ALTER COLUMN "planId" DROP NOT NULL; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 7f8280c..78b6d64 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -52,8 +52,8 @@ model Organization { owner User @relation("OrganizationOwner", fields: [ownerId], references: [id]) memberships Membership[] - planId String - plan Plan @relation(fields: [planId], references: [id]) + planId String? + plan Plan? @relation(fields: [planId], references: [id]) sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithOthers OrganizationLink[] @relation("OrganizationA") diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 627beee..4162cf9 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -32,6 +32,16 @@ const ALL_PERMISSIONS = [ 'TAB_REPORTS_EDIT', ]; +const READ_ONLY_PERMISSIONS = [ + 'TAB_TODAY_READ', + 'TAB_PATIENTS_READ', + 'TAB_APPOINTMENTS_READ', + 'TAB_STAFF_READ', + 'TAB_LAB_READ', + 'TAB_BILLING_READ', + 'TAB_REPORTS_READ', +]; + @Injectable() export class AuthService { constructor( @@ -141,9 +151,7 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, // 'CLINIC' or 'LAB' isOwner: membership.isOwner, - permissions: membership.isOwner - ? ALL_PERMISSIONS - : membership.permissions?.map(p => p.permission.name) || [], + permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, @@ -253,16 +261,13 @@ export class AuthService { async createOrganization(userId: string, dto: CreateOrganizationDto) { const owner = await this.prisma.user.findUnique({ where: { id: userId }, - select: { id: true, trialUsedAt: true }, + select: { id: true }, }); if (!owner) { throw new UnauthorizedException('User not found'); } - const planName = dto.planName?.trim() || 'Small'; - const effectivePlanName = owner.trialUsedAt ? planName : 'trial'; - const organization = await this.prisma.$transaction(async (tx) => { const createdOrganization = await tx.organization.create({ data: { @@ -271,9 +276,6 @@ export class AuthService { owner: { connect: { id: userId }, }, - plan: { - connect: { name: effectivePlanName }, - }, type: { connect: { name: dto.organizationType }, }, @@ -287,14 +289,6 @@ export class AuthService { isOwner: true, }, }); - - if (!owner.trialUsedAt) { - await tx.user.update({ - where: { id: userId }, - data: { trialUsedAt: new Date() }, - }); - } - return createdOrganization; }); @@ -350,9 +344,7 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.isOwner - ? ALL_PERMISSIONS - : membership.permissions?.map(p => p.permission.name) || [], + permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, @@ -471,9 +463,7 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.isOwner - ? ALL_PERMISSIONS - : membership.permissions?.map(p => p.permission.name) || [], + permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, @@ -678,9 +668,7 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.isOwner - ? ALL_PERMISSIONS - : membership.permissions?.map(p => p.permission.name) || [], + permissions: this.getMembershipPermissions(membership), plan: membership.organization.plan ? { name: membership.organization.plan.name, @@ -747,9 +735,7 @@ export class AuthService { }); // 4. Format permissions - const permissions = membership.isOwner - ? ALL_PERMISSIONS - : membership.permissions.map(p => p.permission.name); + const permissions = this.getMembershipPermissions(membership); return { success: true, @@ -789,6 +775,19 @@ export class AuthService { return memberships.filter((m) => m.isOwner || m.isActive); } + private getMembershipPermissions(membership: { + isOwner: boolean; + organization: { + plan?: { name: string; maxUsers: number; price: number } | null; + }; + permissions?: Array<{ permission: { name: string } }>; + }): string[] { + if (membership.isOwner) { + return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS; + } + return membership.permissions?.map((p) => p.permission.name) || []; + } + /** * Owner-only subscription / seat alerts for the current org (from JWT). * Used for a subtle warning indicator in the app shell (not staff-facing banners). @@ -799,6 +798,7 @@ export class AuthService { success: true, data: { showWarning: false, + noActiveSubscription: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, @@ -822,6 +822,7 @@ export class AuthService { success: true, data: { showWarning: false, + noActiveSubscription: false, seatsLow: false, trialEndingSoon: false, trialExpired: false, @@ -833,6 +834,24 @@ export class AuthService { const org = membership.organization; const plan = org.plan; + if (!plan) { + return { + success: true, + data: { + showWarning: true, + noActiveSubscription: true, + seatsLow: false, + trialEndingSoon: false, + trialExpired: false, + seatsUsed: 0, + seatsLimit: null, + daysUntilTrialEnd: null, + trialEndsAt: null, + daysUntilPlanEnd: null, + planEndsAt: null, + }, + }; + } const maxUsers = plan.maxUsers; const seatsUsed = await this.prisma.membership.count({ where: { @@ -863,6 +882,7 @@ export class AuthService { success: true, data: { showWarning, + noActiveSubscription: false, seatsLow, trialEndingSoon, trialExpired, diff --git a/backend/src/modules/staff/staff.service.ts b/backend/src/modules/staff/staff.service.ts index 59880a9..92a1fd5 100644 --- a/backend/src/modules/staff/staff.service.ts +++ b/backend/src/modules/staff/staff.service.ts @@ -59,7 +59,7 @@ export class StaffService { }), ]); - const maxUsers = org.plan.maxUsers; + const maxUsers = org.plan?.maxUsers ?? 0; const unlimited = isUnlimitedSeats(maxUsers); return { @@ -119,6 +119,12 @@ export class StaffService { throw new NotFoundException('Organization not found'); } + if (!org.plan) { + throw new BadRequestException( + 'This organization has no active subscription. Please choose a plan before inviting staff.', + ); + } + const maxUsers = org.plan.maxUsers; const seatsUsed = await tx.membership.count({ where: { @@ -362,7 +368,10 @@ export class StaffService { private async getActorMembership(userId: string, organizationId: string) { return this.prisma.membership.findFirst({ where: { userId, organizationId }, - include: { permissions: { include: { permission: true } } }, + include: { + permissions: { include: { permission: true } }, + organization: { select: { planId: true } }, + }, }); } @@ -424,6 +433,7 @@ export class StaffService { private canViewStaff(m: { isOwner: boolean; + organization?: { planId: string | null }; permissions: { permission: { name: string } }[]; }): boolean { if (m.isOwner) return true; @@ -435,9 +445,10 @@ export class StaffService { private canEditStaff(m: { isOwner: boolean; + organization?: { planId: string | null }; permissions: { permission: { name: string } }[]; }): boolean { - if (m.isOwner) return true; + if (m.isOwner) return Boolean(m.organization?.planId); return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); } } diff --git a/frontend/src/app/(dashboard)/billing/page.tsx b/frontend/src/app/(dashboard)/billing/page.tsx index 19598b0..e92e5fe 100644 --- a/frontend/src/app/(dashboard)/billing/page.tsx +++ b/frontend/src/app/(dashboard)/billing/page.tsx @@ -5,6 +5,8 @@ import { Search, Filter, Plus } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { Input } from '@/components/ui/common/Input'; import { Badge } from '@/components/ui/common/Badge'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { hasPermission } from '@/shared/permissions'; // Mock data matching your design const invoices = [ { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, @@ -25,8 +27,10 @@ interface StatCardProps { color: StatCardColor; } export default function BillingPage() { + const { currentOrganization } = useAuth(); const [search, setSearch] = useState(''); const [statusFilter, setStatusFilter] = useState('all'); + const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT'); const stats = { total: { count: 235, amount: 80900 }, unpaid: { count: 30, amount: 2800 }, @@ -38,7 +42,12 @@ export default function BillingPage() { {/* Header */}

Billing

- @@ -158,7 +167,11 @@ export default function BillingPage() { - diff --git a/frontend/src/app/(dashboard)/patients/page.tsx b/frontend/src/app/(dashboard)/patients/page.tsx index 62ab228..6a3aba9 100644 --- a/frontend/src/app/(dashboard)/patients/page.tsx +++ b/frontend/src/app/(dashboard)/patients/page.tsx @@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react'; import { Plus } from 'lucide-react'; import { Button } from '@/components/ui/common/Button'; import { patientsApi } from '@/lib/api/patients'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { hasPermission } from '@/shared/permissions'; import { CreatePatientInput, CreateTreatmentHistoryInput, @@ -23,6 +25,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = { }; export default function PatientsPage() { + const { currentOrganization } = useAuth(); const [search, setSearch] = useState(''); const [patients, setPatients] = useState([]); const [selectedPatient, setSelectedPatient] = useState(); @@ -35,6 +38,7 @@ export default function PatientsPage() { const [patientForm, setPatientForm] = useState(EMPTY_PATIENT_FORM); const [errorMessage, setErrorMessage] = useState(''); const [successMessage, setSuccessMessage] = useState(''); + const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT'); const sortedPatients = useMemo( () => @@ -154,7 +158,16 @@ export default function PatientsPage() {

Patients

- @@ -189,9 +202,13 @@ export default function PatientsPage() {
diff --git a/frontend/src/app/(dashboard)/settings/account/page.tsx b/frontend/src/app/(dashboard)/settings/account/page.tsx index e7379bb..357f4f9 100644 --- a/frontend/src/app/(dashboard)/settings/account/page.tsx +++ b/frontend/src/app/(dashboard)/settings/account/page.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; export default function AccountSettingsPage() { return ( -
+
+
(null); + const [selectedPlanId, setSelectedPlanId] = useState(PLAN_OPTIONS[0].id); + const [purchaseNotice, setPurchaseNotice] = useState(null); useEffect(() => { if (currentOrganization && !currentOrganization.isOwner) { @@ -38,6 +48,8 @@ export default function SubscriptionsSettingsPage() { } const plan = currentOrganization.plan; + const hasActiveSubscription = Boolean(plan); + const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId); const maxUsers = plan?.maxUsers; const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; const seatsUsed = alert?.seatsUsed; @@ -56,7 +68,7 @@ export default function SubscriptionsSettingsPage() { : 'text-red-400'; return ( -
+
+ {!hasActiveSubscription && ( +
+

+ This organization has no active subscription. Select a plan below to start + the purchase process. +

+
+ )} +

Current plan

@@ -110,6 +131,9 @@ export default function SubscriptionsSettingsPage() { {alert?.showWarning && (
+ {alert.noActiveSubscription && ( +

No active subscription for this organization.

+ )} {alert.trialExpired && (

Trial period has ended. Choose a plan when checkout is available.

)} @@ -124,11 +148,52 @@ export default function SubscriptionsSettingsPage() {
)} -

- Payment and plan upgrades will connect here. The warning on the settings - icon is only shown to workspace owners when seats are low or the trial window - is ending. -

+
+

+ Choose a plan to continue. Purchase integration is not active yet, so this + currently prepares the selection step only. +

+
+ {PLAN_OPTIONS.map((option) => { + const selected = selectedPlanId === option.id; + return ( + + ); + })} +
+ + {purchaseNotice && ( +
+

{purchaseNotice}

+
+ )} +
); diff --git a/frontend/src/app/(dashboard)/staff/page.tsx b/frontend/src/app/(dashboard)/staff/page.tsx index 9249c3f..41a7afa 100644 --- a/frontend/src/app/(dashboard)/staff/page.tsx +++ b/frontend/src/app/(dashboard)/staff/page.tsx @@ -119,6 +119,7 @@ export default function StaffPage() { const [editLoading, setEditLoading] = useState(false); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); + const hasActivePlan = Boolean(currentOrganization?.plan); const atSeatLimit = useMemo(() => { if (!seats || seats.unlimited) return false; if (seats.limit == null) return false; @@ -240,7 +241,7 @@ export default function StaffPage() { } return ( -
+

Staff Management

@@ -248,20 +249,20 @@ export default function StaffPage() { Invite teammates, set tab access, and stay within your plan seat limit.

- {canEdit && ( - - )} +
{seats && ( @@ -273,7 +274,9 @@ export default function StaffPage() { {!seats.unlimited && atSeatLimit && ( - Limit reached — remove a member or upgrade your plan. + {hasActivePlan + ? 'Plan seat limit reached for this organization.' + : 'No active plan selected for this organization. Choose a subscription plan to invite members.'} )}

@@ -356,7 +359,7 @@ export default function StaffPage() { Role Status Access - {canEdit && Actions} + Actions @@ -396,30 +399,44 @@ export default function StaffPage() { )} - {canEdit && ( - - {!m.isOwner && ( -
- - -
- )} - - )} + + {!m.isOwner && ( +
+ + +
+ )} + ))} diff --git a/frontend/src/app/(dashboard)/today/page.tsx b/frontend/src/app/(dashboard)/today/page.tsx index 5e88ac9..5ac0d34 100644 --- a/frontend/src/app/(dashboard)/today/page.tsx +++ b/frontend/src/app/(dashboard)/today/page.tsx @@ -1,10 +1,31 @@ +'use client'; + +import Link from 'next/link'; +import { useAuth } from '@/lib/hooks/useAuth'; + export default function TodayPage() { + const { currentOrganization } = useAuth(); + const showNoSubscriptionNotice = + Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan; + return (

Welcome back Babak !!

+ {showNoSubscriptionNotice && ( +
+

+ This organization does not have an active subscription yet.{' '} + + Choose a plan + {' '} + to start the purchase process. +

+
+ )} +
diff --git a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx index 038ec2b..c9ee022 100644 --- a/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx +++ b/frontend/src/components/ui/dashboard/DashboardAccountMenu.tsx @@ -17,6 +17,7 @@ import type { SubscriptionAlertData } from '@/types/subscription'; function warningTooltip(data: SubscriptionAlertData | null): string { if (!data?.showWarning) return ''; + if (data.noActiveSubscription) return 'No active subscription — review Subscriptions'; if (data.trialExpired) return 'Trial ended — review Subscriptions'; if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; if (data.seatsLow) return 'Seats running low — review Subscriptions'; diff --git a/frontend/src/shared/permissions.ts b/frontend/src/shared/permissions.ts index bafa09b..4a2aa10 100644 --- a/frontend/src/shared/permissions.ts +++ b/frontend/src/shared/permissions.ts @@ -12,7 +12,6 @@ const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ export function hasPermission(org: Organization | null, permission: string): boolean { if (!org) return false; - if (org.isOwner) return true; return Boolean(org.permissions?.includes(permission)); } @@ -33,7 +32,6 @@ export function getRequiredReadPermissionForPath(pathname: string): string | nul /** First dashboard route the user may open (ordered). Fallback: account settings. */ export function firstAccessibleDashboardPath(org: Organization | null): string { if (!org) return '/today'; - if (org.isOwner) return '/today'; for (const { prefix, permission } of ROUTE_TAB_READ) { if (hasPermission(org, permission)) return prefix; } diff --git a/frontend/src/types/subscription.ts b/frontend/src/types/subscription.ts index cd11f65..acb8781 100644 --- a/frontend/src/types/subscription.ts +++ b/frontend/src/types/subscription.ts @@ -1,11 +1,12 @@ /** GET /auth/subscription-alert — owners only get meaningful flags */ export interface SubscriptionAlertData { showWarning: boolean; + noActiveSubscription?: boolean; seatsLow: boolean; trialEndingSoon: boolean; trialExpired: boolean; seatsUsed?: number; - seatsLimit?: number; + seatsLimit?: number | null; daysUntilTrialEnd?: number | null; trialEndsAt?: string | null; daysUntilPlanEnd?: number | null;