Merge pull request 'improvement: newly created organization's subscriptions flow improved.' (#8) from improvement/new-org-subscription into master

Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/8
This commit was merged in pull request #8.
This commit is contained in:
2026-04-30 18:19:26 +03:30
15 changed files with 264 additions and 92 deletions

View File

@@ -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;

View File

@@ -0,0 +1,3 @@
-- Allow organizations without an active subscription plan.
ALTER TABLE "organizations"
ALTER COLUMN "planId" DROP NOT NULL;

View File

@@ -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")

View File

@@ -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,

View File

@@ -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');
}
}

View File

@@ -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 */}
<div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button variant="primary" className="flex items-center gap-2">
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Invoice
</Button>
@@ -158,7 +167,11 @@ export default function BillingPage() {
</Badge>
</td>
<td className="px-6 py-4">
<button className="text-primary hover:opacity-90 text-sm">
<button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
Edit
</button>
</td>

View File

@@ -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<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,6 +38,7 @@ export default function PatientsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo(
() =>
@@ -154,7 +158,16 @@ export default function PatientsPage() {
<div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button variant="primary" className="flex items-center gap-2" onClick={() => setIsCreateOpen(true)}>
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Patient
</Button>
@@ -189,9 +202,13 @@ export default function PatientsPage() {
<div className="flex">
<Button
variant="secondary"
disabled={!selectedPatient}
disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment}
onClick={handleQuickAddTreatment}
onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
Add Quick Treatment Entry
</Button>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
export default function AccountSettingsPage() {
return (
<div className="max-w-xl space-y-6">
<div className="space-y-6">
<div>
<Link
href="/today"

View File

@@ -5,7 +5,7 @@ import { OrganizationSelectorContent } from '@/components/ui/organization/Organi
export default function DashboardOrganizationsSettingsPage() {
return (
<div className="max-w-3xl space-y-6">
<div className="space-y-6">
<div>
<Link
href="/today"

View File

@@ -7,10 +7,20 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
{ id: 'solo', name: 'Solo', maxUsers: 1, price: 19 },
{ id: 'small', name: 'Small', maxUsers: 5, price: 49 },
{ id: 'medium', name: 'Medium', maxUsers: 10, price: 89 },
{ id: 'large', name: 'Large', maxUsers: 15, price: 129 },
{ id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() {
const { currentOrganization } = useAuth();
const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string>(PLAN_OPTIONS[0].id);
const [purchaseNotice, setPurchaseNotice] = useState<string | null>(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 (
<div className="max-w-4xl space-y-6">
<div className="space-y-6">
<div>
<Link
href="/today"
@@ -74,6 +86,15 @@ export default function SubscriptionsSettingsPage() {
</div>
<div className="surface-card p-6 space-y-4">
{!hasActiveSubscription && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization has no active subscription. Select a plan below to start
the purchase process.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div>
<p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p>
@@ -110,6 +131,9 @@ export default function SubscriptionsSettingsPage() {
{alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1">
{alert.noActiveSubscription && (
<p>No active subscription for this organization.</p>
)}
{alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p>
)}
@@ -124,11 +148,52 @@ export default function SubscriptionsSettingsPage() {
</div>
)}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary">
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.
</p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLAN_OPTIONS.map((option) => {
const selected = selectedPlanId === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => setSelectedPlanId(option.id)}
className={`rounded-[var(--radius-md)] border p-4 text-left transition-colors ${
selected
? 'border-primary/70 bg-primary-soft'
: 'border-border hover:border-border-strong'
}`}
>
<p className="text-base font-medium text-text-primary">{option.name}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`}
</p>
<p className="text-sm text-text-secondary mt-1">${option.price} / month</p>
</button>
);
})}
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-60"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
`Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`,
);
}}
>
Start purchase process
</button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>
</div>
)}
</div>
</div>
</div>
);

View File

@@ -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 (
<div className="space-y-6 max-w-5xl">
<div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
@@ -248,20 +249,20 @@ export default function StaffPage() {
Invite teammates, set tab access, and stay within your plan seat limit.
</p>
</div>
{canEdit && (
<Button
size="sm"
onClick={() => {
if (!canEdit || atSeatLimit) return;
setInviteOpen(true);
setLastInviteInfo(null);
}}
disabled={atSeatLimit}
disabled={!canEdit || atSeatLimit}
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
)}
</div>
{seats && (
@@ -273,7 +274,9 @@ export default function StaffPage() {
</span>
{!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2">
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.'}
</span>
)}
</p>
@@ -356,7 +359,7 @@ export default function StaffPage() {
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th>
{canEdit && <th className="p-3 font-medium w-28">Actions</th>}
<th className="p-3 font-medium w-28">Actions</th>
</tr>
</thead>
<tbody>
@@ -396,30 +399,44 @@ export default function StaffPage() {
</span>
)}
</td>
{canEdit && (
<td className="p-3">
{!m.isOwner && (
<div className="flex items-center gap-1">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Edit member"
onClick={() => openEdit(m)}
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
openEdit(m);
}}
>
<Pencil className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Remove member"
onClick={() => void removeMember(m)}
disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
void removeMember(m);
}}
>
<Trash2 className="w-4 h-4" />
</button>
</div>
)}
</td>
)}
</tr>
))}
</tbody>

View File

@@ -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 (
<div>
<h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !!
</h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization does not have an active subscription yet.{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
Choose a plan
</Link>{' '}
to start the purchase process.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" />

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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;