diff --git a/.env b/.env index 6b32982..cfe6997 100644 --- a/.env +++ b/.env @@ -44,6 +44,7 @@ NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true WEBHOOK_SENDER_PROVIDER=postgres # MAILER DEV +CONTACT_EMAIL=info@medreport.ee MAILER_PROVIDER=nodemailer EMAIL_SENDER=info@medreport.ee EMAIL_USER= # refer to your email provider's documentation diff --git a/app/(marketing)/_components/site-header-account-section.tsx b/app/(marketing)/_components/site-header-account-section.tsx index 0407884..bc28a47 100644 --- a/app/(marketing)/_components/site-header-account-section.tsx +++ b/app/(marketing)/_components/site-header-account-section.tsx @@ -24,6 +24,9 @@ const ModeToggle = dynamic(() => const paths = { home: pathsConfig.app.home, + admin: pathsConfig.app.admin, + doctor: pathsConfig.app.doctor, + personalAccountSettings: pathsConfig.app.personalAccountSettings, }; const features = { 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/lib/services/mailer.service.ts b/lib/services/mailer.service.ts index 515bfcc..52a8749 100644 --- a/lib/services/mailer.service.ts +++ b/lib/services/mailer.service.ts @@ -11,7 +11,7 @@ export const sendCompanyOfferEmail = async ( language: string, ) => { const { renderCompanyOfferEmail } = await import('@kit/email-templates'); - const { html, subject, to } = await renderCompanyOfferEmail({ + const { html, subject } = await renderCompanyOfferEmail({ language, companyData: data, }); @@ -19,7 +19,7 @@ export const sendCompanyOfferEmail = async ( await sendEmail({ subject, html, - to, + to: process.env.CONTACT_EMAIL || '', }); }; 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/run-test-sync-local.sh b/run-test-sync-local.sh new file mode 100644 index 0000000..16d9645 --- /dev/null +++ b/run-test-sync-local.sh @@ -0,0 +1,47 @@ +#!/bin/bash + +MEDUSA_ORDER_ID="order_01K1TQQHZGPXKDHAH81TDSNGXR" + +# HOSTNAME="https://test.medreport.ee" +# JOBS_API_TOKEN="fd26ec26-70ed-11f0-9e95-431ac3b15a84" + +HOSTNAME="http://localhost:3000" +JOBS_API_TOKEN=73ce073c-6dd4-11f0-8e75-8fee89786197 + +function send_medipost_test_response() { + curl -X POST "$HOSTNAME/api/order/medipost-test-response" \ + --header "x-jobs-api-key: $JOBS_API_TOKEN" \ + --header 'Content-Type: application/json' \ + --data '{ "medusaOrderId": "'$MEDUSA_ORDER_ID'" }' +} + +function sync_analysis_results() { + curl -X POST "$HOSTNAME/api/job/sync-analysis-results" \ + --header "x-jobs-api-key: $JOBS_API_TOKEN" +} + +function sync_analysis_groups() { + curl -X POST "$HOSTNAME/api/job/sync-analysis-groups" \ + --header "x-jobs-api-key: $JOBS_API_TOKEN" +} + +function sync_analysis_groups_store() { + curl -X POST "$HOSTNAME/api/job/sync-analysis-groups-store" \ + --header "x-jobs-api-key: $JOBS_API_TOKEN" +} + +# Requirements + +# 1. Sync analysis groups from Medipost to B2B +sync_analysis_groups + +# 2. Optional - sync all Medipost analysis groups from B2B to Medusa (or add manually) +#sync_analysis_groups_store + +# 3. Set up products configurations in Medusa so B2B "Telli analüüs" page shows the product and you can do payment flow + +# 4. After payment is done, run `send_medipost_test_response` to send the fake test results to Medipost +# send_medipost_test_response + +# 5. Run `sync_analysis_results` to sync the all new Medipost results to B2B +# sync_analysis_results 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