From 3c6c86c7c88499103bf90c939519121edcf7314d Mon Sep 17 00:00:00 2001
From: Helena <37183360+helenarebane@users.noreply.github.com>
Date: Wed, 13 Aug 2025 12:28:50 +0300
Subject: [PATCH] MED-109: add doctor role and basic view (#45)
* MED-109: add doctor role and basic view
* add role to accounts
* remove old super admin and doctor sql
---
app/doctor/_components/doctor-sidebar.tsx | 63 +++++++++++++++++++
app/doctor/_components/mobile-navigation.tsx | 28 +++++++++
app/doctor/layout.tsx | 47 ++++++++++++++
app/doctor/loading.tsx | 3 +
app/doctor/page.tsx | 17 +++++
.../personal-account-dropdown-container.tsx | 5 +-
config/paths.config.ts | 4 ++
next.config.mjs | 1 +
package.json | 1 +
.../components/personal-account-dropdown.tsx | 62 ++++++++++++++----
packages/features/accounts/src/server/api.ts | 25 +++++---
.../src/components/admin-accounts-table.tsx | 39 ++++++++++++
.../src/lib/server/admin-server-actions.ts | 27 ++++++++
.../lib/server/schema/admin-actions.schema.ts | 9 +++
.../server/services/admin-accounts.service.ts | 15 +++++
packages/features/doctor/eslint.config.mjs | 3 +
packages/features/doctor/package.json | 43 +++++++++++++
.../doctor/src/components/doctor-guard.tsx | 28 +++++++++
packages/features/doctor/src/index.ts | 1 +
.../doctor/src/lib/server/utils/is-doctor.ts | 24 +++++++
packages/features/doctor/tsconfig.json | 10 +++
.../src/components/notifications-popover.tsx | 1 -
packages/supabase/src/database.types.ts | 10 +++
pnpm-lock.yaml | 60 ++++++++++++++++++
public/locales/en/account.json | 7 ++-
public/locales/en/common.json | 3 +-
public/locales/et/account.json | 7 ++-
public/locales/et/common.json | 5 +-
public/locales/ru/account.json | 7 ++-
public/locales/ru/common.json | 3 +-
...0250812121051_create_application_roles.sql | 37 +++++++++++
supabase/sql/super-admin.sql | 2 -
32 files changed, 562 insertions(+), 35 deletions(-)
create mode 100644 app/doctor/_components/doctor-sidebar.tsx
create mode 100644 app/doctor/_components/mobile-navigation.tsx
create mode 100644 app/doctor/layout.tsx
create mode 100644 app/doctor/loading.tsx
create mode 100644 app/doctor/page.tsx
create mode 100644 packages/features/doctor/eslint.config.mjs
create mode 100644 packages/features/doctor/package.json
create mode 100644 packages/features/doctor/src/components/doctor-guard.tsx
create mode 100644 packages/features/doctor/src/index.ts
create mode 100644 packages/features/doctor/src/lib/server/utils/is-doctor.ts
create mode 100644 packages/features/doctor/tsconfig.json
create mode 100644 supabase/migrations/20250812121051_create_application_roles.sql
delete mode 100644 supabase/sql/super-admin.sql
diff --git a/app/doctor/_components/doctor-sidebar.tsx b/app/doctor/_components/doctor-sidebar.tsx
new file mode 100644
index 0000000..2baba9d
--- /dev/null
+++ b/app/doctor/_components/doctor-sidebar.tsx
@@ -0,0 +1,63 @@
+'use client';
+
+import Link from 'next/link';
+import { usePathname } from 'next/navigation';
+
+import { UserWorkspace } from '@/app/home/(user)/_lib/server/load-user-workspace';
+import { LayoutDashboard } from 'lucide-react';
+
+import {
+ Sidebar,
+ SidebarContent,
+ SidebarFooter,
+ SidebarGroup,
+ SidebarGroupContent,
+ SidebarGroupLabel,
+ SidebarHeader,
+ SidebarMenu,
+ SidebarMenuButton,
+ useSidebar,
+} from '@kit/ui/shadcn-sidebar';
+import { Trans } from '@kit/ui/trans';
+
+import { AppLogo } from '~/components/app-logo';
+import { ProfileAccountDropdownContainer } from '~/components/personal-account-dropdown-container';
+
+export function DoctorSidebar({
+ accounts,
+}: {
+ accounts: UserWorkspace['accounts'];
+}) {
+ const path = usePathname();
+ const { open } = useSidebar();
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dashboard
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/doctor/_components/mobile-navigation.tsx b/app/doctor/_components/mobile-navigation.tsx
new file mode 100644
index 0000000..43f0c75
--- /dev/null
+++ b/app/doctor/_components/mobile-navigation.tsx
@@ -0,0 +1,28 @@
+import Link from 'next/link';
+
+import { Menu } from 'lucide-react';
+
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@kit/ui/dropdown-menu';
+import pathsConfig from '../../../config/paths.config';
+
+export function DoctorMobileNavigation() {
+ return (
+
+
+
+
+
+
+
+ Home
+
+
+
+
+ );
+}
diff --git a/app/doctor/layout.tsx b/app/doctor/layout.tsx
new file mode 100644
index 0000000..5210ec3
--- /dev/null
+++ b/app/doctor/layout.tsx
@@ -0,0 +1,47 @@
+import { use } from 'react';
+
+import { cookies } from 'next/headers';
+
+import { Page, PageMobileNavigation, PageNavigation } from '@kit/ui/page';
+import { SidebarProvider } from '@kit/ui/shadcn-sidebar';
+
+
+import { loadUserWorkspace } from '../home/(user)/_lib/server/load-user-workspace';
+import { DoctorSidebar } from './_components/doctor-sidebar';
+import { DoctorMobileNavigation } from './_components/mobile-navigation';
+
+export const metadata = {
+ title: `Doctor`,
+};
+
+export const dynamic = 'force-dynamic';
+
+export default function DoctorLayout(props: React.PropsWithChildren) {
+ const state = use(getLayoutState());
+ const workspace = use(loadUserWorkspace());
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ {props.children}
+
+
+ );
+}
+
+async function getLayoutState() {
+ const cookieStore = await cookies();
+ const sidebarOpenCookie = cookieStore.get('sidebar:state');
+
+ return {
+ open: sidebarOpenCookie?.value !== 'true',
+ };
+}
diff --git a/app/doctor/loading.tsx b/app/doctor/loading.tsx
new file mode 100644
index 0000000..4ea5318
--- /dev/null
+++ b/app/doctor/loading.tsx
@@ -0,0 +1,3 @@
+import { GlobalLoader } from '@kit/ui/global-loader';
+
+export default GlobalLoader;
diff --git a/app/doctor/page.tsx b/app/doctor/page.tsx
new file mode 100644
index 0000000..f789fca
--- /dev/null
+++ b/app/doctor/page.tsx
@@ -0,0 +1,17 @@
+import { DoctorGuard } from '@kit/doctor/components/doctor-guard';
+import { PageBody, PageHeader } from '@kit/ui/page';
+import { Trans } from '@kit/ui/trans';
+
+function DoctorPage() {
+ return (
+ <>
+ } />
+
+
+ TBD
+
+ >
+ );
+}
+
+export default DoctorGuard(DoctorPage);
diff --git a/components/personal-account-dropdown-container.tsx b/components/personal-account-dropdown-container.tsx
index 4e94a8f..52e8e5c 100644
--- a/components/personal-account-dropdown-container.tsx
+++ b/components/personal-account-dropdown-container.tsx
@@ -11,6 +11,9 @@ import pathsConfig from '~/config/paths.config';
const paths = {
home: pathsConfig.app.home,
+ admin: pathsConfig.app.admin,
+ doctor: pathsConfig.app.doctor,
+ personalAccountSettings: pathsConfig.app.personalAccountSettings
};
const features = {
@@ -30,7 +33,7 @@ export function ProfileAccountDropdownContainer(props: {
label: string | null;
value: string | null;
image?: string | null;
-}[]
+ }[];
}) {
const signOut = useSignOut();
const user = useUser(props.user);
diff --git a/config/paths.config.ts b/config/paths.config.ts
index 1a2a2d4..f076dbe 100644
--- a/config/paths.config.ts
+++ b/config/paths.config.ts
@@ -30,6 +30,8 @@ const PathsSchema = z.object({
accountMembers: z.string().min(1),
accountBillingReturn: z.string().min(1),
joinTeam: z.string().min(1),
+ doctor: z.string().min(1),
+ admin: z.string().min(1),
}),
});
@@ -63,6 +65,8 @@ const pathsConfig = PathsSchema.parse({
analysisResults: '/home/analysis-results',
orderAnalysis: '/home/order-analysis',
orderHealthAnalysis: '/home/order-health-analysis',
+ doctor: '/doctor',
+ admin: '/admin',
},
} satisfies z.infer);
diff --git a/next.config.mjs b/next.config.mjs
index b3763d7..14c5b87 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -9,6 +9,7 @@ const INTERNAL_PACKAGES = [
'@kit/auth',
'@kit/accounts',
'@kit/admin',
+ '@kit/doctor',
'@kit/team-accounts',
'@kit/shared',
'@kit/supabase',
diff --git a/package.json b/package.json
index b5cc386..9fe9cad 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"@hookform/resolvers": "^5.1.1",
"@kit/accounts": "workspace:*",
"@kit/admin": "workspace:*",
+ "@kit/doctor": "workspace:*",
"@kit/analytics": "workspace:*",
"@kit/auth": "workspace:*",
"@kit/billing": "workspace:*",
diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx
index 933d099..9141ff1 100644
--- a/packages/features/accounts/src/components/personal-account-dropdown.tsx
+++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx
@@ -6,7 +6,14 @@ import Link from 'next/link';
import type { User } from '@supabase/supabase-js';
-import { ChevronsUpDown, Home, LogOut, Shield, UserCircle } from 'lucide-react';
+import {
+ ChevronsUpDown,
+ Cross,
+ Home,
+ LogOut,
+ Shield,
+ UserCircle,
+} from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@kit/ui/avatar';
import {
@@ -44,16 +51,21 @@ export function PersonalAccountDropdown({
id: string | null;
name: string | null;
picture_url: string | null;
+ application_role: string;
};
accounts: {
label: string | null;
value: string | null;
image?: string | null;
+ application_role: string;
}[];
signOutRequested: () => unknown;
paths: {
home: string;
+ admin: string;
+ doctor: string;
+ personalAccountSettings: string;
};
features: {
@@ -64,10 +76,7 @@ export function PersonalAccountDropdown({
className?: string;
}) {
- const { data: personalAccountData } = usePersonalAccountData(
- user.id,
- account,
- );
+ const { data: personalAccountData } = usePersonalAccountData(user.id);
const signedInAsLabel = useMemo(() => {
const email = user?.email ?? undefined;
@@ -79,15 +88,25 @@ export function PersonalAccountDropdown({
const displayName =
personalAccountData?.name ?? account?.name ?? user?.email ?? '';
- const isSuperAdmin = useMemo(() => {
+ const hasTotpFactor = useMemo(() => {
const factors = user?.factors ?? [];
- const hasAdminRole = user?.app_metadata.role === 'super-admin';
- const hasTotpFactor = factors.some(
+ return factors.some(
(factor) => factor.factor_type === 'totp' && factor.status === 'verified',
);
+ }, [user.factors]);
+
+ const isSuperAdmin = useMemo(() => {
+ const hasAdminRole =
+ personalAccountData?.application_role === 'super_admin';
return hasAdminRole && hasTotpFactor;
- }, [user]);
+ }, [user, personalAccountData, hasTotpFactor]);
+
+ const isDoctor = useMemo(() => {
+ const hasDoctorRole = personalAccountData?.application_role === 'doctor';
+
+ return hasDoctorRole && hasTotpFactor;
+ }, [user, personalAccountData, hasTotpFactor]);
return (
@@ -177,7 +196,7 @@ export function PersonalAccountDropdown({
@@ -209,7 +228,7 @@ export function PersonalAccountDropdown({
@@ -227,7 +246,7 @@ export function PersonalAccountDropdown({
className={
's-full flex cursor-pointer items-center space-x-2 text-yellow-700 dark:text-yellow-500'
}
- href={'/admin'}
+ href={paths.admin}
>
@@ -236,6 +255,25 @@ export function PersonalAccountDropdown({
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/packages/features/accounts/src/server/api.ts b/packages/features/accounts/src/server/api.ts
index 436c2e4..3c080a5 100644
--- a/packages/features/accounts/src/server/api.ts
+++ b/packages/features/accounts/src/server/api.ts
@@ -4,9 +4,13 @@ import { Database } from '@kit/supabase/database';
import { UserAnalysis } from '../types/accounts';
-export type AccountWithParams = Database['medreport']['Tables']['accounts']['Row'] & {
- account_params: Pick | null;
-};
+export type AccountWithParams =
+ Database['medreport']['Tables']['accounts']['Row'] & {
+ account_params: Pick<
+ Database['medreport']['Tables']['account_params']['Row'],
+ 'weight' | 'height'
+ > | null;
+ };
/**
* Class representing an API for interacting with user accounts.
@@ -79,7 +83,8 @@ class AccountsApi {
accounts (
name,
slug,
- picture_url
+ picture_url,
+ application_role
)
`,
)
@@ -95,6 +100,7 @@ class AccountsApi {
label: accounts.name,
value: accounts.slug,
image: accounts.picture_url,
+ application_role: accounts.application_role,
}));
}
@@ -209,11 +215,12 @@ class AccountsApi {
return null;
}
- return analysisResponses
- .map((r) => ({
- ...r,
- elements: analysisResponseElements.filter((e) => e.analysis_response_id === r.id),
- }));
+ return analysisResponses.map((r) => ({
+ ...r,
+ elements: analysisResponseElements.filter(
+ (e) => e.analysis_response_id === r.id,
+ ),
+ }));
}
}
diff --git a/packages/features/admin/src/components/admin-accounts-table.tsx b/packages/features/admin/src/components/admin-accounts-table.tsx
index 0cde270..ab7f0c1 100644
--- a/packages/features/admin/src/components/admin-accounts-table.tsx
+++ b/packages/features/admin/src/components/admin-accounts-table.tsx
@@ -1,5 +1,7 @@
'use client';
+import { useTransition } from 'react';
+
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -11,6 +13,7 @@ import { z } from 'zod';
import { Database } from '@kit/supabase/database';
import { Button } from '@kit/ui/button';
+import { Checkbox } from '@kit/ui/checkbox';
import {
DropdownMenu,
DropdownMenuContent,
@@ -32,7 +35,10 @@ import {
SelectTrigger,
SelectValue,
} from '@kit/ui/select';
+import { toast } from '@kit/ui/sonner';
+import { Trans } from '@kit/ui/trans';
+import { updateRoleAction } from '../lib/server/admin-server-actions';
import { AdminDeleteAccountDialog } from './admin-delete-account-dialog';
import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
@@ -204,6 +210,39 @@ function getColumns(): ColumnDef[] {
header: 'Updated At',
accessorKey: 'updated_at',
},
+ {
+ id: 'isDoctor',
+ header: 'Doctor',
+ cell: ({ row }) => {
+ const [isPending, startTransition] = useTransition();
+
+ const handleToggle = () => {
+ startTransition(async () => {
+ const isDoctor = row.original.application_role === 'doctor';
+ const newRole = isDoctor ? 'user' : 'doctor';
+
+ const promise = updateRoleAction({
+ accountId: row.original.id,
+ role: newRole,
+ });
+
+ toast.promise(() => promise, {
+ success: ,
+ error: ,
+ loading: ,
+ });
+ });
+ };
+
+ return (
+
+ );
+ },
+ },
{
id: 'actions',
header: '',
diff --git a/packages/features/admin/src/lib/server/admin-server-actions.ts b/packages/features/admin/src/lib/server/admin-server-actions.ts
index 7903186..80229fb 100644
--- a/packages/features/admin/src/lib/server/admin-server-actions.ts
+++ b/packages/features/admin/src/lib/server/admin-server-actions.ts
@@ -14,6 +14,7 @@ import {
DeleteUserSchema,
ImpersonateUserSchema,
ReactivateUserSchema,
+ UpdateAccountRoleSchema,
} from './schema/admin-actions.schema';
import { CreateCompanySchema } from './schema/create-company.schema';
import { CreateUserSchema } from './schema/create-user.schema';
@@ -273,6 +274,32 @@ export const createCompanyAccountAction = enhanceAction(
},
);
+/**
+ * @name updateRoleAction
+ * @description Update application role for user
+ */
+export const updateRoleAction = adminAction(
+ enhanceAction(
+ async ({ accountId, role }) => {
+ const service = getAdminAccountsService();
+ const logger = await getLogger();
+
+ logger.info({ accountId }, `Super Admin is updating account role...`);
+
+ await service.updateRole(accountId, role);
+
+ logger.info({ accountId }, `Successfully changed role`);
+
+ revalidateAdmin();
+
+ return { success: true };
+ },
+ {
+ schema: UpdateAccountRoleSchema,
+ },
+ ),
+);
+
function revalidateAdmin() {
revalidatePath('/admin', 'layout');
}
diff --git a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts
index 9506012..8edd356 100644
--- a/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts
+++ b/packages/features/admin/src/lib/server/schema/admin-actions.schema.ts
@@ -1,5 +1,7 @@
import { z } from 'zod';
+import { Database } from '@kit/supabase/database';
+
const ConfirmationSchema = z.object({
confirmation: z.custom((value) => value === 'CONFIRM'),
});
@@ -16,3 +18,10 @@ export const DeleteUserSchema = UserIdSchema;
export const DeleteAccountSchema = ConfirmationSchema.extend({
accountId: z.string().uuid(),
});
+
+type ApplicationRoleType =
+ Database['medreport']['Tables']['accounts']['Row']['application_role'];
+export const UpdateAccountRoleSchema = z.object({
+ accountId: z.string().uuid(),
+ role: z.string() as z.ZodType,
+});
diff --git a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts
index e42e0e0..e06bb55 100644
--- a/packages/features/admin/src/lib/server/services/admin-accounts.service.ts
+++ b/packages/features/admin/src/lib/server/services/admin-accounts.service.ts
@@ -22,4 +22,19 @@ class AdminAccountsService {
throw error;
}
}
+
+ async updateRole(
+ accountId: string,
+ role: Database['medreport']['Tables']['accounts']['Row']['application_role'],
+ ) {
+ const { error } = await this.adminClient
+ .schema('medreport')
+ .from('accounts')
+ .update({ application_role: role })
+ .eq('id', accountId);
+
+ if (error) {
+ throw error;
+ }
+ }
}
diff --git a/packages/features/doctor/eslint.config.mjs b/packages/features/doctor/eslint.config.mjs
new file mode 100644
index 0000000..97563ae
--- /dev/null
+++ b/packages/features/doctor/eslint.config.mjs
@@ -0,0 +1,3 @@
+import eslintConfigBase from '@kit/eslint-config/base.js';
+
+export default eslintConfigBase;
diff --git a/packages/features/doctor/package.json b/packages/features/doctor/package.json
new file mode 100644
index 0000000..48e3d23
--- /dev/null
+++ b/packages/features/doctor/package.json
@@ -0,0 +1,43 @@
+{
+ "name": "@kit/doctor",
+ "private": true,
+ "version": "0.1.0",
+ "scripts": {
+ "clean": "git clean -xdf .turbo node_modules",
+ "format": "prettier --check \"**/*.{ts,tsx}\"",
+ "lint": "eslint .",
+ "typecheck": "tsc --noEmit"
+ },
+ "prettier": "@kit/prettier-config",
+ "devDependencies": {
+ "@hookform/resolvers": "^5.0.1",
+ "@kit/eslint-config": "workspace:*",
+ "@kit/next": "workspace:*",
+ "@kit/prettier-config": "workspace:*",
+ "@kit/shared": "workspace:*",
+ "@kit/supabase": "workspace:*",
+ "@kit/tsconfig": "workspace:*",
+ "@kit/ui": "workspace:*",
+ "@makerkit/data-loader-supabase-core": "^0.0.10",
+ "@makerkit/data-loader-supabase-nextjs": "^1.2.5",
+ "@supabase/supabase-js": "2.49.4",
+ "@tanstack/react-query": "5.76.1",
+ "@tanstack/react-table": "^8.21.3",
+ "@types/react": "19.1.4",
+ "lucide-react": "^0.510.0",
+ "next": "15.3.2",
+ "react": "19.1.0",
+ "react-dom": "19.1.0"
+ },
+ "exports": {
+ ".": "./src/index.ts",
+ "./components/*": "./src/components/*.tsx"
+ },
+ "typesVersions": {
+ "*": {
+ "*": [
+ "src/*"
+ ]
+ }
+ }
+}
diff --git a/packages/features/doctor/src/components/doctor-guard.tsx b/packages/features/doctor/src/components/doctor-guard.tsx
new file mode 100644
index 0000000..afff737
--- /dev/null
+++ b/packages/features/doctor/src/components/doctor-guard.tsx
@@ -0,0 +1,28 @@
+import { notFound } from 'next/navigation';
+
+import { getSupabaseServerClient } from '@kit/supabase/server-client';
+import { isDoctor } from '../lib/server/utils/is-doctor';
+
+
+type LayoutOrPageComponent = React.ComponentType;
+
+/**
+ * DoctorGuard is a server component wrapper that checks if the user is a doctor before rendering the component.
+ * If the user is not a doctor, we redirect to a 404.
+ * @param Component - The Page or Layout component to wrap
+ */
+export function DoctorGuard(
+ Component: LayoutOrPageComponent,
+) {
+ return async function DoctorGuardServerComponentWrapper(params: Params) {
+ const client = getSupabaseServerClient();
+ const isUserDoctor = await isDoctor(client);
+
+ // if the user is not a super-admin, we redirect to a 404
+ if (!isUserDoctor) {
+ notFound();
+ }
+
+ return ;
+ };
+}
diff --git a/packages/features/doctor/src/index.ts b/packages/features/doctor/src/index.ts
new file mode 100644
index 0000000..816fc9d
--- /dev/null
+++ b/packages/features/doctor/src/index.ts
@@ -0,0 +1 @@
+export * from './lib/server/utils/is-doctor';
diff --git a/packages/features/doctor/src/lib/server/utils/is-doctor.ts b/packages/features/doctor/src/lib/server/utils/is-doctor.ts
new file mode 100644
index 0000000..905ee0d
--- /dev/null
+++ b/packages/features/doctor/src/lib/server/utils/is-doctor.ts
@@ -0,0 +1,24 @@
+import { SupabaseClient } from '@supabase/supabase-js';
+
+import { Database } from '@kit/supabase/database';
+
+/**
+ * @name isDoctor
+ * @description Check if the current user is a doctor.
+ * @param client
+ */
+export async function isDoctor(client: SupabaseClient) {
+ try {
+ const { data, error } = await client
+ .schema('medreport')
+ .rpc('is_doctor');
+
+ if (error) {
+ throw error;
+ }
+
+ return data;
+ } catch {
+ return false;
+ }
+}
diff --git a/packages/features/doctor/tsconfig.json b/packages/features/doctor/tsconfig.json
new file mode 100644
index 0000000..7383acd
--- /dev/null
+++ b/packages/features/doctor/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "@kit/tsconfig/base.json",
+ "compilerOptions": {
+ "tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
+ },
+ "include": ["*.ts", "src"],
+ "exclude": [
+ "node_modules"
+ ]
+}
diff --git a/packages/features/notifications/src/components/notifications-popover.tsx b/packages/features/notifications/src/components/notifications-popover.tsx
index 16abbd1..e50a0d0 100644
--- a/packages/features/notifications/src/components/notifications-popover.tsx
+++ b/packages/features/notifications/src/components/notifications-popover.tsx
@@ -228,7 +228,6 @@ export function NotificationsPopover(params: {
size="icon"
variant="ghost"
onClick={() => {
- console.log('test');
setNotifications((existing) => {
return existing.filter(
(existingNotification) =>
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index fa0a47b..8398b9d 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -264,6 +264,7 @@ export type Database = {
}
accounts: {
Row: {
+ application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null
created_at: string | null
created_by: string | null
@@ -284,6 +285,7 @@ export type Database = {
updated_by: string | null
}
Insert: {
+ application_role?: Database["medreport"]["Enums"]["application_role"]
city?: string | null
created_at?: string | null
created_by?: string | null
@@ -304,6 +306,7 @@ export type Database = {
updated_by?: string | null
}
Update: {
+ application_role?: Database["medreport"]["Enums"]["application_role"]
city?: string | null
created_at?: string | null
created_by?: string | null
@@ -1633,6 +1636,7 @@ export type Database = {
create_team_account: {
Args: { account_name: string; new_personal_code: string }
Returns: {
+ application_role: Database["medreport"]["Enums"]["application_role"]
city: string | null
created_at: string | null
created_by: string | null
@@ -1761,6 +1765,10 @@ export type Database = {
Args: { account_slug: string }
Returns: boolean
}
+ is_doctor: {
+ Args: Record
+ Returns: boolean
+ }
is_mfa_compliant: {
Args: Record
Returns: boolean
@@ -1897,6 +1905,7 @@ export type Database = {
| "settings.manage"
| "members.manage"
| "invites.manage"
+ application_role: "user" | "doctor" | "super_admin"
billing_provider: "stripe" | "lemon-squeezy" | "paddle" | "montonio"
notification_channel: "in_app" | "email"
notification_type: "info" | "warning" | "error"
@@ -7791,6 +7800,7 @@ export const Constants = {
"members.manage",
"invites.manage",
],
+ application_role: ["user", "doctor", "super_admin"],
billing_provider: ["stripe", "lemon-squeezy", "paddle", "montonio"],
notification_channel: ["in_app", "email"],
notification_type: ["info", "warning", "error"],
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 5e97367..83aa88c 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -38,6 +38,9 @@ importers:
'@kit/database-webhooks':
specifier: workspace:*
version: link:packages/database-webhooks
+ '@kit/doctor':
+ specifier: workspace:*
+ version: link:packages/features/doctor
'@kit/email-templates':
specifier: workspace:*
version: link:packages/email-templates
@@ -783,6 +786,63 @@ importers:
specifier: ^2.0.3
version: 2.0.5(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ packages/features/doctor:
+ devDependencies:
+ '@hookform/resolvers':
+ specifier: ^5.0.1
+ version: 5.1.1(react-hook-form@7.58.0(react@19.1.0))
+ '@kit/eslint-config':
+ specifier: workspace:*
+ version: link:../../../tooling/eslint
+ '@kit/next':
+ specifier: workspace:*
+ version: link:../../next
+ '@kit/prettier-config':
+ specifier: workspace:*
+ version: link:../../../tooling/prettier
+ '@kit/shared':
+ specifier: workspace:*
+ version: link:../../shared
+ '@kit/supabase':
+ specifier: workspace:*
+ version: link:../../supabase
+ '@kit/tsconfig':
+ specifier: workspace:*
+ version: link:../../../tooling/typescript
+ '@kit/ui':
+ specifier: workspace:*
+ version: link:../../ui
+ '@makerkit/data-loader-supabase-core':
+ specifier: ^0.0.10
+ version: 0.0.10(@supabase/postgrest-js@1.19.4)(@supabase/supabase-js@2.49.4)
+ '@makerkit/data-loader-supabase-nextjs':
+ specifier: ^1.2.5
+ version: 1.2.5(@supabase/postgrest-js@1.19.4)(@supabase/supabase-js@2.49.4)(@tanstack/react-query@5.76.1(react@19.1.0))(next@15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react@19.1.0)
+ '@supabase/supabase-js':
+ specifier: 2.49.4
+ version: 2.49.4
+ '@tanstack/react-query':
+ specifier: 5.76.1
+ version: 5.76.1(react@19.1.0)
+ '@tanstack/react-table':
+ specifier: ^8.21.3
+ version: 8.21.3(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ '@types/react':
+ specifier: 19.1.4
+ version: 19.1.4
+ lucide-react:
+ specifier: ^0.510.0
+ version: 0.510.0(react@19.1.0)
+ next:
+ specifier: 15.3.2
+ version: 15.3.2(@babel/core@7.27.4)(@opentelemetry/api@1.9.0)(babel-plugin-macros@3.1.0)(babel-plugin-react-compiler@19.1.0-rc.2)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+ react:
+ specifier: 19.1.0
+ version: 19.1.0
+ react-dom:
+ specifier: 19.1.0
+ version: 19.1.0(react@19.1.0)
+
packages/features/medusa-storefront:
dependencies:
'@headlessui/react':
diff --git a/public/locales/en/account.json b/public/locales/en/account.json
index edbf616..43ecce4 100644
--- a/public/locales/en/account.json
+++ b/public/locales/en/account.json
@@ -122,5 +122,8 @@
"consentToAnonymizedCompanyData": {
"label": "Consent to be included in employer statistics",
"description": "Consent to be included in anonymized company statistics"
- }
-}
+ },
+ "updateRoleSuccess": "Role updated",
+ "updateRoleError": "Something went wrong, please try again",
+ "updateRoleLoading": "Updating role..."
+}
\ No newline at end of file
diff --git a/public/locales/en/common.json b/public/locales/en/common.json
index ea42f86..b029ffc 100644
--- a/public/locales/en/common.json
+++ b/public/locales/en/common.json
@@ -109,5 +109,6 @@
"description": "This website uses cookies to ensure you get the best experience on our website.",
"reject": "Reject",
"accept": "Accept"
- }
+ },
+ "doctor": "Doctor"
}
\ No newline at end of file
diff --git a/public/locales/et/account.json b/public/locales/et/account.json
index ade6520..7ce015f 100644
--- a/public/locales/et/account.json
+++ b/public/locales/et/account.json
@@ -145,5 +145,8 @@
"successTitle": "Tere, {{firstName}} {{lastName}}",
"successDescription": "Teie tervisekonto on aktiveeritud ja kasutamiseks valmis!",
"successButton": "Jätka"
- }
-}
+ },
+ "updateRoleSuccess": "Roll uuendatud",
+ "updateRoleError": "Midagi läks valesti. Palun proovi uuesti",
+ "updateRoleLoading": "Rolli uuendatakse..."
+}
\ No newline at end of file
diff --git a/public/locales/et/common.json b/public/locales/et/common.json
index 0d069a3..a259c6a 100644
--- a/public/locales/et/common.json
+++ b/public/locales/et/common.json
@@ -127,5 +127,6 @@
"wallet": {
"balance": "Sinu MedReporti konto seis",
"expiredAt": "Kehtiv kuni {{expiredAt}}"
- }
-}
+ },
+ "doctor": "Arst"
+}
\ No newline at end of file
diff --git a/public/locales/ru/account.json b/public/locales/ru/account.json
index 04eb229..cd0d74b 100644
--- a/public/locales/ru/account.json
+++ b/public/locales/ru/account.json
@@ -122,5 +122,8 @@
},
"analysisResults": {
"pageTitle": "My analysis results"
- }
-}
+ },
+ "updateRoleSuccess": "Role updated",
+ "updateRoleError": "Something went wrong, please try again",
+ "updateRoleLoading": "Updating role..."
+}
\ No newline at end of file
diff --git a/public/locales/ru/common.json b/public/locales/ru/common.json
index 6428f91..e0e9728 100644
--- a/public/locales/ru/common.json
+++ b/public/locales/ru/common.json
@@ -107,5 +107,6 @@
"description": "This website uses cookies to ensure you get the best experience on our website.",
"reject": "Reject",
"accept": "Accept"
- }
+ },
+ "doctor": "Doctor"
}
\ No newline at end of file
diff --git a/supabase/migrations/20250812121051_create_application_roles.sql b/supabase/migrations/20250812121051_create_application_roles.sql
new file mode 100644
index 0000000..33cc6e3
--- /dev/null
+++ b/supabase/migrations/20250812121051_create_application_roles.sql
@@ -0,0 +1,37 @@
+create type "medreport"."application_role" as enum ('user', 'doctor', 'super_admin');
+
+ALTER TABLE medreport.accounts
+ADD COLUMN "application_role" medreport.application_role NOT NULL DEFAULT 'user';
+
+CREATE OR REPLACE FUNCTION medreport.is_doctor()
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ RETURN medreport.is_aal2() AND (EXISTS (
+ SELECT 1
+ FROM medreport.accounts
+ WHERE primary_owner_user_id = auth.uid()
+ AND application_role = 'doctor'
+ ));
+END;
+$$;
+grant execute on function medreport.is_doctor() to authenticated;
+
+
+CREATE OR REPLACE FUNCTION medreport.is_super_admin()
+RETURNS BOOLEAN
+LANGUAGE plpgsql
+SECURITY DEFINER
+AS $$
+BEGIN
+ RETURN medreport.is_aal2() AND (EXISTS (
+ SELECT 1
+ FROM medreport.accounts
+ WHERE primary_owner_user_id = auth.uid()
+ AND application_role = 'super_admin'
+ ));
+END;
+$$;
+grant execute on function medreport.is_super_admin() to authenticated;
\ No newline at end of file
diff --git a/supabase/sql/super-admin.sql b/supabase/sql/super-admin.sql
deleted file mode 100644
index 2133379..0000000
--- a/supabase/sql/super-admin.sql
+++ /dev/null
@@ -1,2 +0,0 @@
--- Update your user role to Super Admin
-update auth.users set raw_app_meta_data='{"provider": "email", "providers": ["email"], "role": "super-admin" }' where email='test2@test.ee';
\ No newline at end of file