Merge branch 'develop' into MED-49

This commit is contained in:
Danel Kungla
2025-09-26 17:23:09 +03:00
84 changed files with 1997 additions and 939 deletions

View File

@@ -12,6 +12,7 @@
"./personal-account-settings": "./src/components/personal-account-settings/index.ts",
"./components": "./src/components/index.ts",
"./hooks/*": "./src/hooks/*.ts",
"./services/*": "./src/server/services/*.ts",
"./api": "./src/server/api.ts",
"./types/*": "./src/types/*.ts"
},

View File

@@ -0,0 +1,127 @@
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import type { AccountBalanceEntry } from '../../types/account-balance-entry';
export type AccountBalanceSummary = {
totalBalance: number;
expiringSoon: number;
recentEntries: AccountBalanceEntry[];
};
export class AccountBalanceService {
private supabase: ReturnType<typeof getSupabaseServerClient>;
constructor() {
this.supabase = getSupabaseServerClient();
}
/**
* Get the current balance for a specific account
*/
async getAccountBalance(accountId: string): Promise<number> {
const { data, error } = await this.supabase
.schema('medreport')
.rpc('get_account_balance', {
p_account_id: accountId,
});
if (error) {
console.error('Error getting account balance:', error);
throw new Error('Failed to get account balance');
}
return data || 0;
}
/**
* Get balance entries for an account with pagination
*/
async getAccountBalanceEntries(
accountId: string,
options: {
limit?: number;
offset?: number;
entryType?: string;
includeInactive?: boolean;
} = {}
): Promise<{
entries: AccountBalanceEntry[];
total: number;
}> {
const { limit = 50, offset = 0, entryType, includeInactive = false } = options;
let query = this.supabase
.schema('medreport')
.from('account_balance_entries')
.select('*', { count: 'exact' })
.eq('account_id', accountId)
.order('created_at', { ascending: false })
.range(offset, offset + limit - 1);
if (!includeInactive) {
query = query.eq('is_active', true);
}
if (entryType) {
query = query.eq('entry_type', entryType);
}
const { data, error, count } = await query;
if (error) {
console.error('Error getting account balance entries:', error);
throw new Error('Failed to get account balance entries');
}
return {
entries: data || [],
total: count || 0,
};
}
/**
* Get balance summary for dashboard display
*/
async getBalanceSummary(accountId: string): Promise<AccountBalanceSummary> {
const [balance, entries] = await Promise.all([
this.getAccountBalance(accountId),
this.getAccountBalanceEntries(accountId, { limit: 5 }),
]);
// Calculate expiring balance (next 30 days)
const thirtyDaysFromNow = new Date();
thirtyDaysFromNow.setDate(thirtyDaysFromNow.getDate() + 30);
const { data: expiringData, error: expiringError } = await this.supabase
.schema('medreport')
.from('account_balance_entries')
.select('amount')
.eq('account_id', accountId)
.eq('is_active', true)
.not('expires_at', 'is', null)
.lte('expires_at', thirtyDaysFromNow.toISOString());
if (expiringError) {
console.error('Error getting expiring balance:', expiringError);
}
const expiringSoon = expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
return {
totalBalance: balance,
expiringSoon,
recentEntries: entries.entries,
};
}
async processPeriodicBenefitDistributions(): Promise<void> {
console.info('Processing periodic benefit distributions...');
const { error } = await this.supabase.schema('medreport').rpc('process_periodic_benefit_distributions');
if (error) {
console.error('Error processing periodic benefit distributions:', error);
throw new Error('Failed to process periodic benefit distributions');
}
console.info('Periodic benefit distributions processed successfully');
}
}

View File

@@ -0,0 +1,3 @@
import type { Database } from '@kit/supabase/database';
export type AccountBalanceEntry = Database['medreport']['Tables']['account_balance_entries']['Row'];

View File

@@ -1,23 +1,25 @@
import { Database } from '@kit/supabase/database';
export type ApplicationRole =
Database['medreport']['Tables']['accounts']['Row']['application_role'];
export type ApplicationRole = Account['application_role'];
export enum ApplicationRoleEnum {
User = 'user',
Doctor = 'doctor',
SuperAdmin = 'super_admin',
}
export type AccountWithParams =
Database['medreport']['Tables']['accounts']['Row'] & {
accountParams:
| (Pick<
Database['medreport']['Tables']['account_params']['Row'],
'weight' | 'height'
> & {
isSmoker:
| Database['medreport']['Tables']['account_params']['Row']['is_smoker']
| null;
})
| null;
};
export type AccountParams =
Database['medreport']['Tables']['account_params']['Row'];
export type Account = Database['medreport']['Tables']['accounts']['Row'];
export type AccountWithParams = Account & {
accountParams:
| (Pick<AccountParams, 'weight' | 'height'> & {
isSmoker: AccountParams['is_smoker'] | null;
})
| null;
};
export type CompanyParams =
Database['medreport']['Tables']['company_params']['Row'];
export type BmiThresholds = Database['medreport']['Tables']['bmi_thresholds']['Row'];

View File

@@ -9,6 +9,7 @@
"devDependencies": {
"@hookform/resolvers": "^5.0.1",
"@kit/next": "workspace:*",
"@kit/accounts": "workspace:*",
"@kit/shared": "workspace:*",
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",

View File

@@ -11,7 +11,7 @@ import { EllipsisVertical } from 'lucide-react';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { Database } from '@kit/supabase/database';
import type { Account } from '@kit/accounts/types/accounts';
import { Button } from '@kit/ui/button';
import { Checkbox } from '@kit/ui/checkbox';
import {
@@ -44,8 +44,6 @@ import { AdminDeleteUserDialog } from './admin-delete-user-dialog';
import { AdminImpersonateUserDialog } from './admin-impersonate-user-dialog';
import { AdminResetPasswordDialog } from './admin-reset-password-dialog';
type Account = Database['medreport']['Tables']['accounts']['Row'];
const FiltersSchema = z.object({
type: z.enum(['all', 'team', 'personal']),
query: z.string().optional(),

View File

@@ -1,6 +1,6 @@
import { z } from 'zod';
import { Database } from '@kit/supabase/database';
import { ApplicationRole } from '@kit/accounts/types/accounts';
const ConfirmationSchema = z.object({
confirmation: z.custom<string>((value) => value === 'CONFIRM'),
@@ -19,9 +19,7 @@ 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<ApplicationRoleType>,
role: z.string() as z.ZodType<ApplicationRole>,
});

View File

@@ -3,6 +3,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@kit/supabase/database';
import type { ApplicationRole } from '@kit/accounts/types/accounts';
export function createAdminAccountsService(client: SupabaseClient<Database>) {
return new AdminAccountsService(client);
@@ -25,7 +26,7 @@ class AdminAccountsService {
async updateRole(
accountId: string,
role: Database['medreport']['Tables']['accounts']['Row']['application_role'],
role: ApplicationRole,
) {
const { error } = await this.adminClient
.schema('medreport')

View File

@@ -13,6 +13,7 @@
"@kit/supabase": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:*",
"@kit/user-analyses": "workspace:*",
"@makerkit/data-loader-supabase-core": "^0.0.10",
"@makerkit/data-loader-supabase-nextjs": "^1.2.5",
"@supabase/supabase-js": "2.49.4",

View File

@@ -5,6 +5,7 @@ import { isBefore } from 'date-fns';
import { renderDoctorSummaryReceivedEmail } from '@kit/email-templates';
import { getFullName } from '@kit/shared/utils';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
import { createUserAnalysesApi } from '@kit/user-analyses/api';
import { sendEmailFromTemplate } from '../../../../../../../lib/services/mailer.service';
import { AnalysisResultDetails } from '../schema/doctor-analysis-detail-view.schema';
@@ -641,7 +642,14 @@ export async function submitFeedback(
}
if (status === 'COMPLETED') {
const [{ data: recipient }, { data: analysisOrder }] = await Promise.all([
const { data: analysisOrder } = await supabase
.schema('medreport')
.from('analysis_orders')
.select('medusa_order_id, id')
.eq('id', analysisOrderId)
.limit(1)
.throwOnError();
const [{ data: recipient }] = await Promise.all([
supabase
.schema('medreport')
.from('accounts')
@@ -649,19 +657,10 @@ export async function submitFeedback(
.eq('is_personal_account', true)
.eq('primary_owner_user_id', userId)
.throwOnError(),
supabase
.schema('medreport')
.from('analysis_orders')
.select('medusa_order_id, id')
.eq('id', analysisOrderId)
.limit(1)
.throwOnError(),
supabase
.schema('medreport')
.from('analysis_orders')
.update({ status: 'COMPLETED' })
.eq('id', analysisOrderId)
.throwOnError(),
createUserAnalysesApi(supabase).updateAnalysisOrderStatus({
orderId: analysisOrderId,
orderStatus: 'COMPLETED',
}),
]);
if (!recipient?.[0]?.email) {

View File

@@ -258,6 +258,37 @@ export async function setShippingMethod({
.catch(medusaError);
}
export async function initiateMultiPaymentSession(
cart: HttpTypes.StoreCart,
benefitsAmount: number,
) {
const headers = {
...(await getAuthHeaders()),
};
return sdk.client.fetch<unknown>(`/store/multi-payment`, {
method: 'POST',
body: { cartId: cart.id, benefitsAmount },
headers,
})
.then(async (response) => {
console.info('Payment session initiated:', response);
const cartCacheTag = await getCacheTag('carts');
revalidateTag(cartCacheTag);
return response as {
montonioPaymentSessionId: string | null;
companyBenefitsPaymentSessionId: string | null;
totalByBenefits: number;
totalByMontonio: number;
isFullyPaidByBenefits: boolean;
};
})
.catch((e) => {
console.error('Error initiating payment session:', e, JSON.stringify(Object.keys(e)));
return medusaError(e);
});
}
export async function initiatePaymentSession(
cart: HttpTypes.StoreCart,
data: HttpTypes.StoreInitializePaymentSession,

View File

@@ -6,7 +6,7 @@ import { HttpTypes } from '@medusajs/types';
import { getAuthHeaders, getCacheOptions } from './cookies';
export const retrieveOrder = async (id: string) => {
export const retrieveOrder = async (id: string, allowCache = true) => {
const headers = {
...(await getAuthHeaders()),
};
@@ -24,7 +24,7 @@ export const retrieveOrder = async (id: string) => {
},
headers,
next,
cache: 'force-cache',
...(allowCache ? { cache: 'force-cache' } : {}),
})
.then(({ order }) => order)
.catch((err) => medusaError(err));
@@ -61,11 +61,6 @@ export const listOrders = async (
};
export const createTransferRequest = async (
state: {
success: boolean;
error: string | null;
order: HttpTypes.StoreOrder | null;
},
formData: FormData,
): Promise<{
success: boolean;

View File

@@ -1,6 +1,5 @@
import Image from 'next/image';
import { useDismissNotification } from '@kit/notifications/hooks';
import { Heading } from '@kit/ui/heading';
import { If } from '@kit/ui/if';
import { Separator } from '@kit/ui/separator';
@@ -12,7 +11,7 @@ import { SignOutInvitationButton } from './sign-out-invitation-button';
export function AcceptInvitationContainer(props: {
inviteToken: string;
email: string;
fullName: string;
invitation: {
id: string;
@@ -76,7 +75,7 @@ export function AcceptInvitationContainer(props: {
/>
<InvitationSubmitButton
email={props.email}
fullName={props.fullName}
accountName={props.invitation.account.name}
/>
</form>

View File

@@ -7,7 +7,7 @@ import { Trans } from '@kit/ui/trans';
export function InvitationSubmitButton(props: {
accountName: string;
email: string;
fullName: string;
}) {
const { pending } = useFormStatus();
@@ -17,7 +17,7 @@ export function InvitationSubmitButton(props: {
i18nKey={pending ? 'teams:joiningTeam' : 'teams:continueAs'}
values={{
accountName: props.accountName,
email: props.email,
fullName: props.fullName,
}}
/>
</Button>

View File

@@ -20,6 +20,7 @@ import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input';
import { ProfileAvatar } from '@kit/ui/profile-avatar';
import { Trans } from '@kit/ui/trans';
import { formatCurrency } from '@kit/shared/utils';
import { RemoveMemberDialog } from './remove-member-dialog';
import { RoleBadge } from './role-badge';
@@ -42,6 +43,10 @@ type AccountMembersTableProps = {
userRoleHierarchy: number;
isPrimaryOwner: boolean;
canManageRoles: boolean;
membersBenefitsUsage: {
personal_account_id: string;
benefit_amount: number;
}[];
};
export function AccountMembersTable({
@@ -51,6 +56,7 @@ export function AccountMembersTable({
isPrimaryOwner,
userRoleHierarchy,
canManageRoles,
membersBenefitsUsage,
}: AccountMembersTableProps) {
const [search, setSearch] = useState('');
const { t } = useTranslation('teams');
@@ -73,6 +79,7 @@ export function AccountMembersTable({
currentUserId,
currentAccountId,
currentRoleHierarchy: userRoleHierarchy,
membersBenefitsUsage,
});
const filteredMembers = members
@@ -122,9 +129,13 @@ function useGetColumns(
currentUserId: string;
currentAccountId: string;
currentRoleHierarchy: number;
membersBenefitsUsage: {
personal_account_id: string;
benefit_amount: number;
}[];
},
): ColumnDef<Members[0]>[] {
const { t } = useTranslation('teams');
const { t, i18n: { language } } = useTranslation('teams');
return useMemo(
() => [
@@ -168,6 +179,23 @@ function useGetColumns(
return row.original.personal_code ?? '-';
},
},
{
header: t('distributedBenefitsAmount'),
cell: ({ row }) => {
const benefitAmount = params.membersBenefitsUsage.find(
(usage) => usage.personal_account_id === row.original.id
)?.benefit_amount;
if (typeof benefitAmount !== 'number') {
return '-';
}
return formatCurrency({
currencyCode: 'EUR',
locale: language,
value: benefitAmount,
});
},
},
{
header: t('roleLabel'),
cell: ({ row }) => {
@@ -175,7 +203,7 @@ function useGetColumns(
const isPrimaryOwner = primary_owner_user_id === user_id;
return (
<span className={'flex items-center space-x-1'}>
<span className={'flex items-center space-x-1 flex-wrap space-y-1 sm:space-y-0 sm:flex-nowrap'}>
<RoleBadge role={role} />
<If condition={isPrimaryOwner}>

View File

@@ -13,6 +13,8 @@ import { TeamAccountDangerZone } from './team-account-danger-zone';
import { UpdateTeamAccountImage } from './update-team-account-image-container';
import { UpdateTeamAccountNameForm } from './update-team-account-name-form';
const SHOW_TEAM_LOGO = false as boolean;
export function TeamAccountSettingsContainer(props: {
account: {
name: string;
@@ -32,21 +34,23 @@ export function TeamAccountSettingsContainer(props: {
}) {
return (
<div className={'flex w-full flex-col space-y-4'}>
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams:settings.teamLogo'} />
</CardTitle>
{SHOW_TEAM_LOGO && (
<Card>
<CardHeader>
<CardTitle>
<Trans i18nKey={'teams:settings.teamLogo'} />
</CardTitle>
<CardDescription>
<Trans i18nKey={'teams:settings.teamLogoDescription'} />
</CardDescription>
</CardHeader>
<CardDescription>
<Trans i18nKey={'teams:settings.teamLogoDescription'} />
</CardDescription>
</CardHeader>
<CardContent>
<UpdateTeamAccountImage account={props.account} />
</CardContent>
</Card>
<CardContent>
<UpdateTeamAccountImage account={props.account} />
</CardContent>
</Card>
)}
<Card>
<CardHeader>

View File

@@ -18,6 +18,7 @@ import { RenewInvitationSchema } from '../../schema/renew-invitation.schema';
import { UpdateInvitationSchema } from '../../schema/update-invitation.schema';
import { createAccountInvitationsService } from '../services/account-invitations.service';
import { createAccountPerSeatBillingService } from '../services/account-per-seat-billing.service';
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
/**
* @name createInvitationsAction
@@ -148,6 +149,7 @@ export const updateInvitationAction = enhanceAction(
export const acceptInvitationAction = enhanceAction(
async (data: FormData, user) => {
const client = getSupabaseServerClient();
const accountBalanceService = new AccountBalanceService();
const { inviteToken, nextPath } = AcceptInvitationSchema.parse(
Object.fromEntries(data),
@@ -171,6 +173,9 @@ export const acceptInvitationAction = enhanceAction(
throw new Error('Failed to accept invitation');
}
// Make sure new account gets company benefits added to balance
await accountBalanceService.processPeriodicBenefitDistributions();
// Increase the seats for the account
await perSeatBillingService.increaseSeats(accountId);

View File

@@ -1,9 +1,7 @@
import { z } from 'zod';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
type Account = Database['medreport']['Tables']['accounts']['Row'];
import type { Account } from '@kit/accounts/types/accounts';
export function createAccountWebhooksService() {
return new AccountWebhooksService();

View File

@@ -4,7 +4,7 @@ import type { UuringuVastus } from '@kit/shared/types/medipost-analysis';
import { toArray } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database';
import type { AnalysisOrder } from '../types/analysis-orders';
import type { AnalysisOrder, AnalysisOrderStatus } from '../types/analysis-orders';
import type {
AnalysisResultDetailsElement,
AnalysisResultDetailsMapped,
@@ -450,6 +450,32 @@ class UserAnalysesApi {
return data;
}
async updateAnalysisOrderStatus({
orderId,
medusaOrderId,
orderStatus,
}: {
orderId?: number;
medusaOrderId?: string;
orderStatus: AnalysisOrderStatus;
}) {
const orderIdParam = orderId;
const medusaOrderIdParam = medusaOrderId;
console.info(`Updating order id=${orderId} medusaOrderId=${medusaOrderId} status=${orderStatus}`);
if (!orderIdParam && !medusaOrderIdParam) {
throw new Error('Either orderId or medusaOrderId must be provided');
}
await this.client
.schema('medreport')
.rpc('update_analysis_order_status', {
order_id: orderIdParam ?? -1,
status_param: orderStatus,
medusa_order_id_param: medusaOrderIdParam ?? '',
})
.throwOnError();
}
}
export function createUserAnalysesApi(client: SupabaseClient<Database>) {

View File

@@ -1,3 +1,4 @@
import { Tables } from '@kit/supabase/database';
export type AnalysisOrder = Tables<{ schema: 'medreport' }, 'analysis_orders'>;
export type AnalysisOrderStatus = AnalysisOrder['status'];

View File

@@ -1,4 +1,4 @@
import { CreditCard, LayoutDashboard, Settings, Users } from 'lucide-react';
import { Euro, LayoutDashboard, Settings, Users } from 'lucide-react';
import { NavigationConfigSchema } from '@kit/ui/navigation-schema';
@@ -11,28 +11,28 @@ const getRoutes = (account: string) => [
{
children: [
{
label: 'common:routes.dashboard',
label: 'common:routes.companyDashboard',
path: pathsConfig.app.accountHome.replace('[account]', account),
Icon: <LayoutDashboard className={iconClasses} />,
end: true,
},
{
label: 'common:routes.settings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
{
label: 'common:routes.members',
label: 'common:routes.companyMembers',
path: createPath(pathsConfig.app.accountMembers, account),
Icon: <Users className={iconClasses} />,
},
featureFlagsConfig.enableTeamAccountBilling
? {
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <CreditCard className={iconClasses} />,
}
label: 'common:routes.billing',
path: createPath(pathsConfig.app.accountBilling, account),
Icon: <Euro className={iconClasses} />,
}
: undefined,
{
label: 'common:routes.companySettings',
path: createPath(pathsConfig.app.accountSettings, account),
Icon: <Settings className={iconClasses} />,
},
].filter(Boolean),
},
];

View File

@@ -11,10 +11,10 @@ import { getSupabaseClientKeys } from '../get-supabase-client-keys';
* @name getSupabaseServerClient
* @description Creates a Supabase client for use in the Server.
*/
export function getSupabaseServerClient<GenericSchema = Database>() {
export function getSupabaseServerClient() {
const keys = getSupabaseClientKeys();
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
return createServerClient<Database>(keys.url, keys.anonKey, {
auth: {
flowType: 'pkce',
autoRefreshToken: true,

View File

@@ -348,6 +348,8 @@ export type Database = {
expires_at: string | null
id: string
is_active: boolean
is_analysis_order: boolean
is_analysis_package_order: boolean
reference_id: string | null
source_company_id: string | null
}
@@ -361,6 +363,8 @@ export type Database = {
expires_at?: string | null
id?: string
is_active?: boolean
is_analysis_order?: boolean
is_analysis_package_order?: boolean
reference_id?: string | null
source_company_id?: string | null
}
@@ -374,6 +378,8 @@ export type Database = {
expires_at?: string | null
id?: string
is_active?: boolean
is_analysis_order?: boolean
is_analysis_package_order?: boolean
reference_id?: string | null
source_company_id?: string | null
}
@@ -2310,6 +2316,15 @@ export type Database = {
user_id: string
}[]
}
get_benefits_usages_for_company_members: {
Args: {
p_account_id: string
}
Returns: {
personal_account_id: string
benefit_amount: number
}
}
get_config: {
Args: Record<PropertyKey, never>
Returns: Json