fix account balance for deleted users
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import { getSupabaseServerClient } from '@kit/supabase/server-client';
|
||||
|
||||
import type { AccountBalanceEntry } from '../../types/account-balance-entry';
|
||||
import { createAccountsApi } from '../api';
|
||||
|
||||
export type AccountBalanceSummary = {
|
||||
totalBalance: number;
|
||||
@@ -88,6 +89,11 @@ export class AccountBalanceService {
|
||||
* Get balance summary for dashboard display
|
||||
*/
|
||||
async getBalanceSummary(accountId: string): Promise<AccountBalanceSummary> {
|
||||
const api = createAccountsApi(this.supabase);
|
||||
|
||||
const hasAccountTeamMembership =
|
||||
await api.hasAccountTeamMembership(accountId);
|
||||
|
||||
const [balance, entries] = await Promise.all([
|
||||
this.getAccountBalance(accountId),
|
||||
this.getAccountBalanceEntries(accountId, { limit: 5 }),
|
||||
@@ -113,6 +119,14 @@ export class AccountBalanceService {
|
||||
const expiringSoon =
|
||||
expiringData?.reduce((sum, entry) => sum + (entry.amount || 0), 0) || 0;
|
||||
|
||||
if (!hasAccountTeamMembership) {
|
||||
return {
|
||||
totalBalance: 0,
|
||||
expiringSoon,
|
||||
recentEntries: entries.entries,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
totalBalance: balance,
|
||||
expiringSoon,
|
||||
|
||||
@@ -32,7 +32,7 @@ type Members =
|
||||
Database['medreport']['Functions']['get_account_members']['Returns'];
|
||||
|
||||
interface Permissions {
|
||||
canUpdateRole: (roleHierarchy: number) => boolean;
|
||||
canUpdateRole: boolean;
|
||||
canRemoveFromAccount: (roleHierarchy: number) => boolean;
|
||||
canTransferOwnership: boolean;
|
||||
canUpdateBenefit: boolean;
|
||||
@@ -67,11 +67,7 @@ export function AccountMembersTable({
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
const permissions = {
|
||||
canUpdateRole: (targetRole: number) => {
|
||||
return (
|
||||
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
|
||||
);
|
||||
},
|
||||
canUpdateRole: canManageRoles,
|
||||
canRemoveFromAccount: (targetRole: number) => {
|
||||
return (
|
||||
isPrimaryOwner || (canManageRoles && userRoleHierarchy < targetRole)
|
||||
@@ -271,7 +267,6 @@ function ActionsDropdown({
|
||||
const isPrimaryOwner = member.primary_owner_user_id === member.user_id;
|
||||
|
||||
const memberRoleHierarchy = member.role_hierarchy_level;
|
||||
const canUpdateRole = permissions.canUpdateRole(memberRoleHierarchy);
|
||||
|
||||
const canRemoveFromAccount =
|
||||
permissions.canRemoveFromAccount(memberRoleHierarchy);
|
||||
@@ -279,9 +274,10 @@ function ActionsDropdown({
|
||||
// if has no permission to update role, transfer ownership or remove from account
|
||||
// do not render the dropdown menu
|
||||
if (
|
||||
!canUpdateRole &&
|
||||
!permissions.canUpdateRole &&
|
||||
!permissions.canTransferOwnership &&
|
||||
!canRemoveFromAccount
|
||||
!canRemoveFromAccount &&
|
||||
!permissions.canUpdateBenefit
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
@@ -296,7 +292,7 @@ function ActionsDropdown({
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<DropdownMenuContent>
|
||||
<If condition={canUpdateRole && !isPrimaryOwner}>
|
||||
<If condition={permissions.canUpdateRole}>
|
||||
<DropdownMenuItem onClick={() => setIsUpdatingRole(true)}>
|
||||
<Trans i18nKey={'teams:updateRole'} />
|
||||
</DropdownMenuItem>
|
||||
|
||||
@@ -37,13 +37,10 @@ import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { InviteMembersSchema } from '../../schema/invite-members.schema';
|
||||
import { createInvitationsAction } from '../../server/actions/team-invitations-server-actions';
|
||||
import { MembershipRoleSelector } from './membership-role-selector';
|
||||
import { RolesDataProvider } from './roles-data-provider';
|
||||
|
||||
type InviteModel = ReturnType<typeof createEmptyInviteModel>;
|
||||
|
||||
type Role = string;
|
||||
|
||||
/**
|
||||
* The maximum number of invites that can be sent at once.
|
||||
* Useful to avoid spamming the server with too large payloads
|
||||
@@ -66,10 +63,7 @@ export function InviteMembersDialogContainer({
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen} modal>
|
||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||
|
||||
<DialogContent
|
||||
className="max-w-[800px]"
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogContent onInteractOutside={(e) => e.preventDefault()}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
<Trans i18nKey={'teams:inviteMembersHeading'} />
|
||||
@@ -81,10 +75,9 @@ export function InviteMembersDialogContainer({
|
||||
</DialogHeader>
|
||||
|
||||
<RolesDataProvider maxRoleHierarchy={userRoleHierarchy}>
|
||||
{(roles) => (
|
||||
{() => (
|
||||
<InviteMembersForm
|
||||
pending={pending}
|
||||
roles={roles}
|
||||
onSubmit={(data) => {
|
||||
startTransition(() => {
|
||||
const promise = createInvitationsAction({
|
||||
@@ -111,12 +104,10 @@ export function InviteMembersDialogContainer({
|
||||
|
||||
function InviteMembersForm({
|
||||
onSubmit,
|
||||
roles,
|
||||
pending,
|
||||
}: {
|
||||
onSubmit: (data: { invitations: InviteModel[] }) => void;
|
||||
pending: boolean;
|
||||
roles: string[];
|
||||
}) {
|
||||
const { t } = useTranslation('teams');
|
||||
|
||||
@@ -148,12 +139,11 @@ function InviteMembersForm({
|
||||
const personalCodeInputName =
|
||||
`invitations.${index}.personal_code` as const;
|
||||
const emailInputName = `invitations.${index}.email` as const;
|
||||
const roleInputName = `invitations.${index}.role` as const;
|
||||
|
||||
return (
|
||||
<div data-test={'invite-member-form-item'} key={field.id}>
|
||||
<div className={'flex items-end gap-x-1 md:space-x-2'}>
|
||||
<div className={'w-4/12'}>
|
||||
<div data-test="invite-member-form-item" key={field.id}>
|
||||
<div className="flex items-end gap-x-1 md:space-x-2">
|
||||
<div className="w-5/12">
|
||||
<FormField
|
||||
name={personalCodeInputName}
|
||||
render={({ field }) => {
|
||||
@@ -178,7 +168,7 @@ function InviteMembersForm({
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={'w-4/12'}>
|
||||
<div className={'w-5/12'}>
|
||||
<FormField
|
||||
name={emailInputName}
|
||||
render={({ field }) => {
|
||||
@@ -205,37 +195,7 @@ function InviteMembersForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'w-4/12'}>
|
||||
<FormField
|
||||
name={roleInputName}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem>
|
||||
<If condition={isFirst}>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'teams:roleLabel'} />
|
||||
</FormLabel>
|
||||
</If>
|
||||
|
||||
<FormControl>
|
||||
<MembershipRoleSelector
|
||||
triggerClassName={'m-0'}
|
||||
roles={roles}
|
||||
value={field.value}
|
||||
onChange={(role) => {
|
||||
form.setValue(field.name, role);
|
||||
}}
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'flex w-[40px] items-end justify-end'}>
|
||||
<div className={'flex w-1/12 items-end justify-end'}>
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -303,5 +263,5 @@ function InviteMembersForm({
|
||||
}
|
||||
|
||||
function createEmptyInviteModel() {
|
||||
return { email: '', role: 'member' as Role, personal_code: '' };
|
||||
return { email: '', personal_code: '' };
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import { z } from 'zod';
|
||||
|
||||
const InviteSchema = z.object({
|
||||
email: z.string().email(),
|
||||
role: z.string().min(1).max(100),
|
||||
personal_code: z
|
||||
.string()
|
||||
.regex(/^[1-6]\d{2}(0[1-9]|1[0-2])(0[1-9]|[12]\d|3[01])\d{3}\d$/, {
|
||||
|
||||
@@ -5,7 +5,6 @@ import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AccountBalanceService } from '@kit/accounts/services/account-balance.service';
|
||||
import { enhanceAction } from '@kit/next/actions';
|
||||
import { createNotificationsApi } from '@kit/notifications/api';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
|
||||
@@ -191,7 +191,10 @@ class AccountInvitationsService {
|
||||
const response = await this.client
|
||||
.schema('medreport')
|
||||
.rpc('add_invitations_to_account', {
|
||||
invitations,
|
||||
invitations: invitations.map((invitation) => ({
|
||||
...invitation,
|
||||
role: 'member',
|
||||
})),
|
||||
account_slug: accountSlug,
|
||||
});
|
||||
|
||||
|
||||
47
supabase/migrations/20251009180300_fix_member_management.sql
Normal file
47
supabase/migrations/20251009180300_fix_member_management.sql
Normal file
@@ -0,0 +1,47 @@
|
||||
drop policy "Allow select and update if user is account's primary owner" on medreport.company_params;
|
||||
|
||||
create policy "Allow select and update if user is account's HR"
|
||||
on medreport.company_params
|
||||
for all
|
||||
using (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM medreport.accounts_memberships am
|
||||
WHERE am.account_id = company_params.account_id
|
||||
AND am.user_id = auth.uid()
|
||||
AND am.account_role = 'owner'
|
||||
)
|
||||
)
|
||||
with check (
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM medreport.accounts_memberships am
|
||||
WHERE am.account_id = company_params.account_id
|
||||
AND am.user_id = auth.uid()
|
||||
AND am.account_role = 'owner'
|
||||
)
|
||||
);
|
||||
|
||||
create or replace function medreport.clear_benefit_amount_on_employee_deletion()
|
||||
returns trigger
|
||||
language plpgsql
|
||||
security definer
|
||||
set search_path = medreport, public
|
||||
as $$
|
||||
begin
|
||||
update medreport.account_balance_entries abe
|
||||
set amount = 0
|
||||
where abe.account_id = old.user_id
|
||||
AND abe.entry_type = 'benefit';
|
||||
|
||||
return null;
|
||||
end;
|
||||
$$;
|
||||
|
||||
drop trigger if exists trigger_accounts_memberships_after_delete
|
||||
on medreport.accounts_memberships;
|
||||
|
||||
create trigger trigger_accounts_memberships_after_delete
|
||||
after delete on medreport.accounts_memberships
|
||||
for each row
|
||||
execute function medreport.clear_benefit_amount_on_employee_deletion();
|
||||
Reference in New Issue
Block a user