diff --git a/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql b/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql new file mode 100644 index 0000000..023adfc --- /dev/null +++ b/backend/prisma/migrations/20260429134000_add_user_trial_used_at/migration.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" +ADD COLUMN "trialUsedAt" TIMESTAMP(3); diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f6517c6..db7b5fa 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -15,6 +15,7 @@ model User { googleId String? @unique facebookId String? @unique name String + trialUsedAt DateTime? memberships Membership[] ownedOrganizations Organization[] @relation("OrganizationOwner") diff --git a/backend/prisma/seed.ts b/backend/prisma/seed.ts index 73d51d8..532c14d 100644 --- a/backend/prisma/seed.ts +++ b/backend/prisma/seed.ts @@ -1,6 +1,5 @@ // backend/prisma/seed.ts import { PrismaClient } from '@prisma/client'; -import * as bcrypt from 'bcrypt'; import { config } from 'dotenv'; import path from 'path'; @@ -29,14 +28,14 @@ async function main() { console.log('✅ Database connected successfully'); // Create organization types - const clinicType = await prisma.organizationType.upsert({ + await prisma.organizationType.upsert({ where: { name: 'CLINIC' }, update: {}, create: { name: 'CLINIC' }, }); console.log('✅ Created clinic type'); - const labType = await prisma.organizationType.upsert({ + await prisma.organizationType.upsert({ where: { name: 'LAB' }, update: {}, create: { name: 'LAB' }, @@ -61,32 +60,39 @@ async function main() { } console.log('✅ Created plans'); - // Create features and permissions + // Minimal permission model (confirmed): + // - Sidebar tabs use READ/EDIT + // - EDIT implies READ in app logic + // - Owners effectively get all permissions const features = [ { - name: 'Patient Management', - permissions: ['VIEW_PATIENTS', 'CREATE_PATIENTS', 'EDIT_PATIENTS', 'DELETE_PATIENTS'] + name: 'Today', + permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], }, { - name: 'Order Management', - permissions: ['VIEW_ORDERS', 'CREATE_ORDERS', 'EDIT_ORDERS', 'DELETE_ORDERS', 'TRACK_ORDERS'] + name: 'Patients', + permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], }, { - name: 'Case Management', - permissions: ['VIEW_CASES', 'CREATE_CASES', 'EDIT_CASES', 'DELETE_CASES'] + name: 'Appointments', + permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], }, { - name: 'Reports', - permissions: ['VIEW_REPORTS', 'EXPORT_REPORTS'] + name: 'Staff Management', + permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], }, { - name: 'Team Management', - permissions: ['INVITE_USERS', 'REMOVE_USERS', 'MANAGE_PERMISSIONS'] + name: 'Lab Management', + permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'], }, { name: 'Billing', - permissions: ['VIEW_INVOICES', 'CREATE_INVOICES', 'MANAGE_PAYMENTS'] - } + permissions: ['TAB_BILLING_READ', 'TAB_BILLING_EDIT'], + }, + { + name: 'Reports', + permissions: ['TAB_REPORTS_READ', 'TAB_REPORTS_EDIT'], + }, ]; for (const feature of features) { @@ -109,7 +115,7 @@ async function main() { } console.log('✅ Created features and permissions'); - console.log('🌱 Seeding completed successfully!'); `` + console.log('🌱 Seeding completed successfully!'); } main() diff --git a/backend/src/modules/auth/auth.controller.ts b/backend/src/modules/auth/auth.controller.ts index afc0b62..860aade 100644 --- a/backend/src/modules/auth/auth.controller.ts +++ b/backend/src/modules/auth/auth.controller.ts @@ -25,6 +25,7 @@ import { import { AuthService } from './auth.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtAuthGuard } from './guards/jwt-auth.guard'; import { LocalAuthGuard } from './guards/local-auth.guard'; @@ -120,6 +121,14 @@ export class AuthController { }; } + @Post('organizations') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ summary: 'Create organization for current user' }) + async createOrganization(@Req() req, @Body() dto: CreateOrganizationDto) { + return this.authService.createOrganization(req.user.id, dto); + } + // ========================= // PROFILE // ========================= @@ -136,6 +145,20 @@ export class AuthController { return this.authService.getProfile(req.user.id); } + @Get('subscription-alert') + @UseGuards(JwtAuthGuard) + @ApiBearerAuth('JWT-auth') + @ApiOperation({ + summary: + 'Owner-only: seat / trial status for warning indicator (current org from JWT)', + }) + async getSubscriptionAlert(@Req() req) { + return this.authService.getOwnerSubscriptionAlert( + req.user.id, + req.user.organizationId, + ); + } + // ========================= // LOGOUT // ========================= diff --git a/backend/src/modules/auth/auth.service.ts b/backend/src/modules/auth/auth.service.ts index 9b23699..e041323 100644 --- a/backend/src/modules/auth/auth.service.ts +++ b/backend/src/modules/auth/auth.service.ts @@ -12,30 +12,24 @@ import * as bcrypt from 'bcrypt'; import { PrismaService } from '../../../prisma/prisma.service'; import { LoginDto } from './dto/login.dto'; import { RegisterDto } from './dto/register.dto'; +import { CreateOrganizationDto } from './dto/create-organization.dto'; import { JwtPayload } from './interfaces/jwt-payload.interface'; const ALL_PERMISSIONS = [ - 'VIEW_PATIENTS', - 'CREATE_PATIENTS', - 'EDIT_PATIENTS', - 'DELETE_PATIENTS', - 'VIEW_ORDERS', - 'CREATE_ORDERS', - 'EDIT_ORDERS', - 'DELETE_ORDERS', - 'TRACK_ORDERS', - 'VIEW_CASES', - 'CREATE_CASES', - 'EDIT_CASES', - 'DELETE_CASES', - 'VIEW_REPORTS', - 'EXPORT_REPORTS', - 'INVITE_USERS', - 'REMOVE_USERS', - 'MANAGE_PERMISSIONS', - 'VIEW_INVOICES', - 'CREATE_INVOICES', - 'MANAGE_PAYMENTS', + 'TAB_TODAY_READ', + 'TAB_TODAY_EDIT', + 'TAB_PATIENTS_READ', + 'TAB_PATIENTS_EDIT', + 'TAB_APPOINTMENTS_READ', + 'TAB_APPOINTMENTS_EDIT', + 'TAB_STAFF_READ', + 'TAB_STAFF_EDIT', + 'TAB_LAB_READ', + 'TAB_LAB_EDIT', + 'TAB_BILLING_READ', + 'TAB_BILLING_EDIT', + 'TAB_REPORTS_READ', + 'TAB_REPORTS_EDIT', ]; @Injectable() @@ -62,6 +56,7 @@ export class AuthService { organization: { include: { type: true, // Include organization type (CLINIC/LAB) + plan: true, } }, permissions: { @@ -148,6 +143,12 @@ export class AuthService { permissions: membership.isOwner ? ALL_PERMISSIONS : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -176,7 +177,7 @@ export class AuthService { * @returns Created user info without password */ async register(registerDto: RegisterDto) { - const { email, password, name, organizationName, organizationType } = registerDto; + const { email, password, name, organizationName, organizationEmail, organizationType } = registerDto; // 1. Check existing user const existingUser = await this.prisma.user.findUnique({ @@ -184,7 +185,7 @@ export class AuthService { }); if (existingUser) { - throw new ConflictException('User already exists'); + throw new ConflictException('User already exists. Please login and create a new organization from your account.'); } // 2. Hash password @@ -198,16 +199,15 @@ export class AuthService { email, passwordHash: hashedPassword, name, + trialUsedAt: new Date(), }, }); // Create organization const organization = await tx.organization.create({ data: { - name: registerDto.organizationName, - - // REQUIRED FIELDS 👇 - email: registerDto.email, // or separate org email if you have one + name: organizationName, + email: organizationEmail, owner: { connect: { id: user.id }, @@ -219,7 +219,7 @@ export class AuthService { type: { connect: { - name: registerDto.organizationType, // 'CLINIC' | 'LAB' + name: organizationType, // 'CLINIC' | 'LAB' }, }, }, @@ -247,6 +247,66 @@ export class AuthService { return this.login({ email, password } as any, validatedUser); } + async createOrganization(userId: string, dto: CreateOrganizationDto) { + const owner = await this.prisma.user.findUnique({ + where: { id: userId }, + select: { id: true, trialUsedAt: 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: { + name: dto.organizationName, + email: dto.organizationEmail, + owner: { + connect: { id: userId }, + }, + plan: { + connect: { name: effectivePlanName }, + }, + type: { + connect: { name: dto.organizationType }, + }, + }, + }); + + await tx.membership.create({ + data: { + userId, + organizationId: createdOrganization.id, + isOwner: true, + }, + }); + + if (!owner.trialUsedAt) { + await tx.user.update({ + where: { id: userId }, + data: { trialUsedAt: new Date() }, + }); + } + + return createdOrganization; + }); + + return { + success: true, + data: { + organization: { + id: organization.id, + name: organization.name, + email: organization.email, + }, + }, + }; + } + /** * Get user profile with all memberships and permissions * @param userId - User ID from JWT token @@ -262,6 +322,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -286,7 +347,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -352,6 +421,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -397,7 +467,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -569,6 +647,7 @@ export class AuthService { organization: { include: { type: true, + plan: true, }, }, permissions: { @@ -594,7 +673,15 @@ export class AuthService { name: membership.organization.name, type: membership.organization.type.name, isOwner: membership.isOwner, - permissions: membership.permissions?.map(p => p.permission.name) || [], + permissions: membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions?.map(p => p.permission.name) || [], + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, })) || []; return { @@ -617,6 +704,7 @@ export class AuthService { organizationId, }, include: { + user: true, organization: { include: { type: true, @@ -638,7 +726,7 @@ export class AuthService { // 2. Build payload WITH org context const payload = { sub: userId, - email: membership.organization.email, + email: membership.user.email, organizationId: membership.organizationId, type: 'access', }; @@ -650,7 +738,9 @@ export class AuthService { }); // 4. Format permissions - const permissions = membership.permissions.map(p => p.permission.name); + const permissions = membership.isOwner + ? ALL_PERMISSIONS + : membership.permissions.map(p => p.permission.name); return { success: true, @@ -660,9 +750,101 @@ export class AuthService { id: membership.organization.id, name: membership.organization.name, type: membership.organization.type.name, + isOwner: membership.isOwner, + plan: membership.organization.plan + ? { + name: membership.organization.plan.name, + maxUsers: membership.organization.plan.maxUsers, + } + : undefined, }, permissions, }, }; } + + /** + * 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). + */ + async getOwnerSubscriptionAlert(userId: string, organizationId: string | undefined) { + if (!organizationId) { + return { + success: true, + data: { + showWarning: false, + seatsLow: false, + trialEndingSoon: false, + trialExpired: false, + }, + }; + } + + const membership = await this.prisma.membership.findFirst({ + where: { userId, organizationId }, + include: { + organization: { + include: { plan: true }, + }, + }, + }); + + if (!membership || !membership.isOwner) { + return { + success: true, + data: { + showWarning: false, + seatsLow: false, + trialEndingSoon: false, + trialExpired: false, + }, + }; + } + + const org = membership.organization; + const plan = org.plan; + const maxUsers = plan.maxUsers; + const seatsUsed = await this.prisma.membership.count({ + where: { organizationId: org.id }, + }); + + const unlimited = maxUsers >= 999999; + const remaining = unlimited ? Infinity : maxUsers - seatsUsed; + const seatsLow = + !unlimited && remaining >= 0 && remaining <= 2 && maxUsers > 0; + + let trialEndingSoon = false; + let trialExpired = false; + let daysUntilTrialEnd: number | null = null; + let trialEndsAt: string | null = null; + + if (plan.name === 'trial') { + const end = new Date(org.createdAt); + end.setDate(end.getDate() + 30); + trialEndsAt = end.toISOString(); + const ms = end.getTime() - Date.now(); + daysUntilTrialEnd = Math.ceil(ms / (1000 * 60 * 60 * 24)); + if (daysUntilTrialEnd <= 0) { + trialExpired = true; + } else if (daysUntilTrialEnd <= 7) { + trialEndingSoon = true; + } + } + + const showWarning = seatsLow || trialEndingSoon || trialExpired; + + return { + success: true, + data: { + showWarning, + seatsLow, + trialEndingSoon, + trialExpired, + seatsUsed, + seatsLimit: maxUsers, + daysUntilTrialEnd, + trialEndsAt, + }, + }; + } } \ No newline at end of file diff --git a/backend/src/modules/auth/dto/create-organization.dto.ts b/backend/src/modules/auth/dto/create-organization.dto.ts new file mode 100644 index 0000000..716744d --- /dev/null +++ b/backend/src/modules/auth/dto/create-organization.dto.ts @@ -0,0 +1,16 @@ +import { IsEmail, IsEnum, IsOptional, IsString } from 'class-validator'; + +export class CreateOrganizationDto { + @IsString() + organizationName: string; + + @IsEmail() + organizationEmail: string; + + @IsEnum(['CLINIC', 'LAB']) + organizationType: 'CLINIC' | 'LAB'; + + @IsOptional() + @IsString() + planName?: string; +} diff --git a/backend/src/modules/auth/dto/register.dto.ts b/backend/src/modules/auth/dto/register.dto.ts index ed11b46..3556870 100644 --- a/backend/src/modules/auth/dto/register.dto.ts +++ b/backend/src/modules/auth/dto/register.dto.ts @@ -14,6 +14,9 @@ export class RegisterDto { @IsString() organizationName: string; + @IsEmail() + organizationEmail: string; + @IsEnum(['CLINIC', 'LAB']) organizationType: 'CLINIC' | 'LAB'; } \ No newline at end of file diff --git a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts index ab2f27c..6182b3e 100644 --- a/backend/src/modules/auth/interfaces/jwt-payload.interface.ts +++ b/backend/src/modules/auth/interfaces/jwt-payload.interface.ts @@ -2,6 +2,7 @@ export interface JwtPayload { sub: string; // user id email: string; + organizationId?: string; type?: 'access' | 'refresh'; } diff --git a/frontend/src/app/(dashboard)/layout.tsx b/frontend/src/app/(dashboard)/layout.tsx index 62735b2..c23c6d9 100644 --- a/frontend/src/app/(dashboard)/layout.tsx +++ b/frontend/src/app/(dashboard)/layout.tsx @@ -1,18 +1,15 @@ 'use client'; -import { memo, useCallback, useEffect } from 'react'; +import { memo, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { useAuth } from '@/lib/hooks/useAuth'; import Sidebar from '@/components/ui/Sidebar'; import { ThemeToggle } from '@/components/ui/ThemeToggle'; -import { LogOut } from 'lucide-react'; +import { DashboardAccountMenu } from '@/components/ui/DashboardAccountMenu'; export default function DashboardLayout({ children }: { children: React.ReactNode }) { - const { user, currentOrganization, isAuthReady, logout } = useAuth(); + const { user, currentOrganization, isAuthReady } = useAuth(); const router = useRouter(); - const handleLogout = useCallback(() => { - void logout(); - }, [logout]); // ✅ AUTH GUARD (runs once per navigation group) useEffect(() => { @@ -51,11 +48,7 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
- +
@@ -69,27 +62,16 @@ export default function DashboardLayout({ children }: { children: React.ReactNod const DashboardHeader = memo(function DashboardHeader({ organizationName, - userName, - onLogout, }: { organizationName: string; - userName: string; - onLogout: () => void; }) { return ( -
-

{organizationName}

+
+

{organizationName}

-
+
- {userName} - +
); diff --git a/frontend/src/app/(dashboard)/settings/account/page.tsx b/frontend/src/app/(dashboard)/settings/account/page.tsx new file mode 100644 index 0000000..e7379bb --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/account/page.tsx @@ -0,0 +1,29 @@ +'use client'; + +import Link from 'next/link'; + +export default function AccountSettingsPage() { + return ( +
+
+ + ← Back to app + +

Account

+

+ Profile and security settings for your login. +

+
+ +
+

+ Password change and profile editing will be wired here next (e.g. invite + flow, reset password). +

+
+
+ ); +} diff --git a/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx new file mode 100644 index 0000000..7ac3bb3 --- /dev/null +++ b/frontend/src/app/(dashboard)/settings/subscriptions/page.tsx @@ -0,0 +1,103 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useAuth } from '@/lib/hooks/useAuth'; +import { authApi } from '@/lib/api/auth'; +import type { SubscriptionAlertData } from '@/types'; + +export default function SubscriptionsSettingsPage() { + const { currentOrganization } = useAuth(); + const router = useRouter(); + const [alert, setAlert] = useState(null); + + useEffect(() => { + if (currentOrganization && !currentOrganization.isOwner) { + router.replace('/today'); + } + }, [currentOrganization, router]); + + useEffect(() => { + if (!currentOrganization?.isOwner) return; + void authApi.getSubscriptionAlert().then((r) => { + if (r.success) setAlert(r.data); + }); + }, [currentOrganization?.id, currentOrganization?.isOwner]); + + if (!currentOrganization) { + return ( +

Loading...

+ ); + } + + if (!currentOrganization.isOwner) { + return ( +

Redirecting...

+ ); + } + + const plan = currentOrganization.plan; + const maxUsers = plan?.maxUsers; + + return ( +
+
+ + ← Back to app + +

Subscriptions

+

+ Your DyoLink workspace plan and seats for{' '} + {currentOrganization.name}. + Clinic and lab income tracking stays under the sidebar{' '} + Billing tab. +

+
+ +
+
+
+

Current plan

+

+ {plan?.name ?? '—'} +

+
+ {typeof maxUsers === 'number' && maxUsers < 999999 && ( +
+

Seats (this org)

+

+ {alert?.seatsUsed ?? '—'} / {maxUsers} +

+
+ )} +
+ + {alert?.showWarning && ( +
+ {alert.trialExpired && ( +

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

+ )} + {!alert.trialExpired && alert.trialEndingSoon && ( +

+ Trial ends in {alert.daysUntilTrialEnd ?? '—'} day(s). +

+ )} + {!alert.trialExpired && !alert.trialEndingSoon && alert.seatsLow && ( +

Seat usage is high for this organization.

+ )} +
+ )} + +

+ 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. +

+
+
+ ); +} diff --git a/frontend/src/app/(public)/register/page.tsx b/frontend/src/app/(public)/register/page.tsx index b37192e..03fc30b 100644 --- a/frontend/src/app/(public)/register/page.tsx +++ b/frontend/src/app/(public)/register/page.tsx @@ -18,6 +18,7 @@ const registerSchema = z.object({ .regex(/[0-9]/, 'Password must contain at least one number'), confirmPassword: z.string(), organizationName: z.string().min(2, 'Organization name must be at least 2 characters'), + organizationEmail: z.string().email('Please enter a valid organization email'), organizationType: z.enum(['CLINIC', 'LAB'], { message: 'Please select organization type', }), @@ -47,7 +48,7 @@ export default function RegisterPage() { const handleNext = async () => { const fieldsToValidate = step === 1 ? ['name', 'email', 'password', 'confirmPassword'] - : ['organizationName', 'organizationType']; + : ['organizationName', 'organizationEmail', 'organizationType']; const isValid = await trigger(fieldsToValidate as any); if (isValid) { @@ -62,6 +63,7 @@ export default function RegisterPage() { data.password, data.name, data.organizationName, + data.organizationEmail, data.organizationType ); // No need to redirect - auth context will handle it @@ -182,6 +184,14 @@ export default function RegisterPage() { error={errors.organizationName?.message} icon={} /> + } + />