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 */}