Merge pull request #39 from MR-medreport/MED-111

MED-111: fix company creation for admin and inviting of new employees
This commit is contained in:
danelkungla
2025-08-04 11:33:08 +03:00
committed by GitHub
15 changed files with 481 additions and 40 deletions

3
.env
View File

@@ -42,7 +42,10 @@ NEXT_PUBLIC_LANGUAGE_PRIORITY=application
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
WEBHOOK_SENDER_PROVIDER=postgres
# MAILER DEV # MAILER DEV
MAILER_PROVIDER=nodemailer
EMAIL_SENDER=info@medreport.ee EMAIL_SENDER=info@medreport.ee
EMAIL_USER= # refer to your email provider's documentation EMAIL_USER= # refer to your email provider's documentation
EMAIL_PASSWORD= # refer to your email provider's documentation EMAIL_PASSWORD= # refer to your email provider's documentation

View File

@@ -163,9 +163,9 @@ export function PersonalAccountDropdown({
</Link> </Link>
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuSeparator />
<If condition={accounts.length > 0}> <If condition={accounts.length > 0}>
<DropdownMenuSeparator />
<span className="text-muted-foreground px-2 text-xs"> <span className="text-muted-foreground px-2 text-xs">
<Trans <Trans
i18nKey={'teams:yourTeams'} i18nKey={'teams:yourTeams'}

View File

@@ -20,7 +20,6 @@ import { Button } from '@kit/ui/button';
import { import {
Form, Form,
FormControl, FormControl,
FormDescription,
FormField, FormField,
FormItem, FormItem,
FormLabel, FormLabel,
@@ -29,10 +28,13 @@ import {
import { If } from '@kit/ui/if'; import { If } from '@kit/ui/if';
import { Input } from '@kit/ui/input'; import { Input } from '@kit/ui/input';
import { toast } from '@kit/ui/sonner'; import { toast } from '@kit/ui/sonner';
import { Trans } from '@kit/ui/trans';
import { createCompanyAccountAction } from '../lib/server/admin-server-actions'; import { createCompanyAccountAction } from '../lib/server/admin-server-actions';
import { CreateCompanySchema, CreateCompanySchemaType } from '../lib/server/schema/create-company.schema'; import {
import { Trans } from '@kit/ui/trans'; CreateCompanySchema,
CreateCompanySchemaType,
} from '../lib/server/schema/create-company.schema';
export function AdminCreateCompanyDialog(props: React.PropsWithChildren) { export function AdminCreateCompanyDialog(props: React.PropsWithChildren) {
const [pending, startTransition] = useTransition(); const [pending, startTransition] = useTransition();
@@ -58,14 +60,9 @@ export function AdminCreateCompanyDialog(props: React.PropsWithChildren) {
setOpen(false); setOpen(false);
setError(null); setError(null);
} else { } else {
setError('Something went wrong with company creation'); setError('Something went wrong with company creation');
} }
} catch (e) { } catch (e) {
setError(e instanceof Error ? e.message : 'Error'); setError(e instanceof Error ? e.message : 'Error');
} }
@@ -100,17 +97,17 @@ export function AdminCreateCompanyDialog(props: React.PropsWithChildren) {
</If> </If>
<FormField <FormField
name={'name'} name="name"
render={({ field }) => { render={({ field }) => {
return ( return (
<FormItem> <FormItem>
<FormLabel> <FormLabel>
<Trans i18nKey={'teams:teamNameLabel'} /> <Trans i18nKey="teams:teamNameLabel" />
</FormLabel> </FormLabel>
<FormControl> <FormControl>
<Input <Input
data-test={'create-team-name-input'} data-test="create-team-name-input"
required required
minLength={2} minLength={2}
maxLength={50} maxLength={50}
@@ -119,9 +116,31 @@ export function AdminCreateCompanyDialog(props: React.PropsWithChildren) {
/> />
</FormControl> </FormControl>
<FormDescription> <FormMessage />
<Trans i18nKey={'teams:teamNameDescription'} /> </FormItem>
</FormDescription> );
}}
/>
<FormField
name="ownerPersonalCode"
render={({ field }) => {
return (
<FormItem>
<FormLabel>
<Trans i18nKey="teams:teamOwnerPersonalCodeLabel" />
</FormLabel>
<FormControl>
<Input
data-test="create-team-owner-personal-code-input"
required
minLength={2}
maxLength={50}
placeholder={''}
{...field}
/>
</FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View File

@@ -239,7 +239,7 @@ export const resetPasswordAction = adminAction(
); );
export const createCompanyAccountAction = enhanceAction( export const createCompanyAccountAction = enhanceAction(
async ({ name }, user) => { async ({ name, ownerPersonalCode }, user) => {
const logger = await getLogger(); const logger = await getLogger();
const client = getSupabaseServerClient(); const client = getSupabaseServerClient();
const service = createCreateCompanyAccountService(client); const service = createCreateCompanyAccountService(client);
@@ -254,7 +254,7 @@ export const createCompanyAccountAction = enhanceAction(
const { data, error } = await service.createNewOrganizationAccount({ const { data, error } = await service.createNewOrganizationAccount({
name, name,
userId: user.id, ownerPersonalCode,
}); });
if (error) { if (error) {
@@ -266,8 +266,7 @@ export const createCompanyAccountAction = enhanceAction(
} }
logger.info(ctx, `Company account created`); logger.info(ctx, `Company account created`);
redirect(`/admin/accounts/${data.id}`);
redirect(`/home/${data.slug}/settings`);
}, },
{ {
schema: CreateCompanySchema, schema: CreateCompanySchema,

View File

@@ -1,5 +1,19 @@
import Isikukood from 'isikukood';
import { z } from 'zod'; import { z } from 'zod';
const personalCodeSchema = z.string().refine(
(val) => {
try {
return new Isikukood(val).validate();
} catch {
return false;
}
},
{
message: 'Invalid personal code',
},
);
/** /**
* @name RESERVED_NAMES_ARRAY * @name RESERVED_NAMES_ARRAY
* @description Array of reserved names for team accounts * @description Array of reserved names for team accounts
@@ -46,7 +60,7 @@ export const CompanyNameSchema = z
*/ */
export const CreateCompanySchema = z.object({ export const CreateCompanySchema = z.object({
name: CompanyNameSchema, name: CompanyNameSchema,
ownerPersonalCode: personalCodeSchema,
}); });
export type CreateCompanySchemaType = z.infer<typeof CreateCompanySchema>; export type CreateCompanySchemaType = z.infer<typeof CreateCompanySchema>;

View File

@@ -16,7 +16,10 @@ class CreateTeamAccountService {
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
async createNewOrganizationAccount(params: { name: string; userId: string }) { async createNewOrganizationAccount(params: {
name: string;
ownerPersonalCode: string;
}) {
const logger = await getLogger(); const logger = await getLogger();
const ctx = { ...params, namespace: this.namespace }; const ctx = { ...params, namespace: this.namespace };
@@ -26,12 +29,13 @@ class CreateTeamAccountService {
.schema('medreport') .schema('medreport')
.rpc('create_team_account', { .rpc('create_team_account', {
account_name: params.name, account_name: params.name,
new_personal_code: params.ownerPersonalCode,
}); });
if (error) { if (error) {
logger.error( logger.error(
{ {
error, error: error.message,
...ctx, ...ctx,
}, },
`Error creating company account`, `Error creating company account`,

View File

@@ -206,6 +206,10 @@ function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
}); });
if (response.error) { if (response.error) {
console.warn(
{ error: response.error.message },
'Failed to verify MFA challenge',
);
throw response.error; throw response.error;
} }

View File

@@ -2,6 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js'; import { SupabaseClient } from '@supabase/supabase-js';
import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
type Notification = Database['medreport']['Tables']['notifications']; type Notification = Database['medreport']['Tables']['notifications'];
@@ -14,12 +15,17 @@ class NotificationsService {
constructor(private readonly client: SupabaseClient<Database>) {} constructor(private readonly client: SupabaseClient<Database>) {}
async createNotification(params: Notification['Insert']) { async createNotification(params: Notification['Insert']) {
const logger = await getLogger();
const { error } = await this.client const { error } = await this.client
.schema('medreport') .schema('medreport')
.from('notifications') .from('notifications')
.insert(params); .insert(params);
if (error) { if (error) {
logger.error(
{ ...params },
`Could not create notification: ${error.message}`,
);
throw error; throw error;
} }
} }

View File

@@ -54,8 +54,9 @@ export const createInvitationsAction = enhanceAction(
); );
} }
const { data: invitations, error: invitationError } = const { data: invitations, error: invitationError } = await serviceClient
await serviceClient.rpc('get_invitations_with_account_ids', { .schema('medreport')
.rpc('get_invitations_with_account_ids', {
company_id: company[0].id, company_id: company[0].id,
personal_codes: personalCodes, personal_codes: personalCodes,
}); });

View File

@@ -51,7 +51,10 @@ class AccountInvitationsService {
}); });
if (error) { if (error) {
logger.error(ctx, `Failed to remove invitation`); logger.error(
{ ...ctx, error: error.message },
`Failed to remove invitation`,
);
throw error; throw error;
} }

View File

@@ -218,10 +218,24 @@ export type Database = {
{ {
foreignKeyName: "account_params_account_id_fkey" foreignKeyName: "account_params_account_id_fkey"
columns: ["account_id"] columns: ["account_id"]
isOneToOne: true isOneToOne: false
referencedRelation: "accounts" referencedRelation: "accounts"
referencedColumns: ["id"] referencedColumns: ["id"]
}, },
{
foreignKeyName: "account_params_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "account_params_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
] ]
} }
accounts: { accounts: {
@@ -316,7 +330,13 @@ export type Database = {
user_id?: string user_id?: string
} }
Relationships: [ Relationships: [
{
foreignKeyName: "accounts_memberships_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{ {
foreignKeyName: "accounts_memberships_account_id_fkey" foreignKeyName: "accounts_memberships_account_id_fkey"
columns: ["account_id"] columns: ["account_id"]
@@ -932,6 +952,7 @@ export type Database = {
id: number id: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code: string | null
role: string role: string
updated_at: string updated_at: string
} }
@@ -943,6 +964,7 @@ export type Database = {
id?: number id?: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code?: string | null
role: string role: string
updated_at?: string updated_at?: string
} }
@@ -954,6 +976,7 @@ export type Database = {
id?: number id?: number
invite_token?: string invite_token?: string
invited_by?: string invited_by?: string
personal_code?: string | null
role?: string role?: string
updated_at?: string updated_at?: string
} }
@@ -1543,7 +1566,7 @@ export type Database = {
add_invitations_to_account: { add_invitations_to_account: {
Args: { Args: {
account_slug: string account_slug: string
invitations: Database["public"]["CompositeTypes"]["invitation"][] invitations: Database["medreport"]["CompositeTypes"]["invitation"][]
} }
Returns: Database["medreport"]["Tables"]["invitations"]["Row"][] Returns: Database["medreport"]["Tables"]["invitations"]["Row"][]
} }
@@ -1561,6 +1584,7 @@ export type Database = {
id: number id: number
invite_token: string invite_token: string
invited_by: string invited_by: string
personal_code: string | null
role: string role: string
updated_at: string updated_at: string
} }
@@ -1577,7 +1601,9 @@ export type Database = {
Returns: Json Returns: Json
} }
create_team_account: { create_team_account: {
Args: { account_name: string } Args:
| { account_name: string }
| { account_name: string; new_personal_code: string }
Returns: { Returns: {
city: string | null city: string | null
created_at: string | null created_at: string | null
@@ -1609,6 +1635,7 @@ export type Database = {
created_at: string created_at: string
updated_at: string updated_at: string
expires_at: string expires_at: string
personal_code: string
inviter_name: string inviter_name: string
inviter_email: string inviter_email: string
}[] }[]
@@ -1633,6 +1660,14 @@ export type Database = {
Args: Record<PropertyKey, never> Args: Record<PropertyKey, never>
Returns: Json Returns: Json
} }
get_invitations_with_account_ids: {
Args: { company_id: string; personal_codes: string[] }
Returns: {
invite_token: string
personal_code: string
account_id: string
}[]
}
get_nonce_status: { get_nonce_status: {
Args: { p_id: string } Args: { p_id: string }
Returns: Json Returns: Json
@@ -1846,7 +1881,11 @@ export type Database = {
| "paused" | "paused"
} }
CompositeTypes: { CompositeTypes: {
[_ in never]: never invitation: {
email: string | null
role: string | null
personal_code: string | null
}
} }
} }
public: { public: {
@@ -7531,14 +7570,6 @@ export type Database = {
[_ in never]: never [_ in never]: never
} }
Functions: { Functions: {
get_invitations_with_account_ids: {
Args: { company_id: string; personal_codes: string[] }
Returns: {
invite_token: string
personal_code: string
account_id: string
}[]
}
has_permission: { has_permission: {
Args: { Args: {
user_id: string user_id: string

View File

@@ -160,5 +160,6 @@
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.", "leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.", "reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.", "specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
"personalCode": "Isikukood" "personalCode": "Isikukood",
"teamOwnerPersonalCodeLabel": "Omaniku isikukood"
} }

View File

@@ -0,0 +1,280 @@
drop function if exists medreport.add_invitations_to_account(text, medreport.invitation[]);
drop type if exists medreport.invitation;
create type medreport.invitation as (
email text,
role text,
personal_code char(11)
);
create or replace function medreport.add_invitations_to_account (
account_slug text,
invitations medreport.invitation[]
) returns medreport.invitations[]
set search_path = ''
as $$
declare
new_invitation medreport.invitations;
all_invitations medreport.invitations[] := array[]::medreport.invitations[];
invite_token text;
invite medreport.invitation;
begin
foreach invite in array invitations loop
invite_token := extensions.uuid_generate_v4();
insert into medreport.invitations (
email,
account_id,
invited_by,
role,
invite_token,
personal_code
)
values (
invite.email,
(select id from medreport.accounts where slug = account_slug),
auth.uid(),
invite.role,
invite_token,
invite.personal_code
)
returning * into new_invitation;
all_invitations := array_append(all_invitations, new_invitation);
end loop;
return all_invitations;
end;
$$ language plpgsql;
grant execute on function medreport.add_invitations_to_account(text, medreport.invitation[]) to authenticated;
grant
execute on function medreport.has_permission (uuid, uuid, medreport.app_permissions) to authenticated,
service_role;
grant
execute on function medreport.has_more_elevated_role (uuid, uuid, varchar) to authenticated,
service_role;
grant
execute on function medreport.has_same_role_hierarchy_level (uuid, uuid, varchar) to authenticated,
service_role;
ALTER TABLE medreport.invitations ALTER COLUMN id DROP DEFAULT;
ALTER TABLE medreport.invitations DROP COLUMN id;
ALTER TABLE medreport.invitations ADD COLUMN id UUID NOT NULL DEFAULT gen_random_uuid();
ALTER TABLE medreport.invitations ADD PRIMARY KEY (id);
ALTER TABLE medreport.invitations DROP CONSTRAINT invitations_pkey;
ALTER TABLE medreport.invitations DROP COLUMN id;
ALTER TABLE medreport.invitations ADD COLUMN id SERIAL PRIMARY KEY;
DROP FUNCTION IF EXISTS medreport.get_account_invitations(account_slug text);
create function medreport.get_account_invitations (account_slug text)
returns table (
id integer,
email varchar(255),
account_id uuid,
invited_by uuid,
role varchar(50),
created_at timestamptz,
updated_at timestamptz,
expires_at timestamptz,
personal_code text,
inviter_name varchar,
inviter_email varchar
)
set search_path = ''
as $$
begin
return query
select
invitation.id,
invitation.email,
invitation.account_id,
invitation.invited_by,
invitation.role,
invitation.created_at,
invitation.updated_at,
invitation.expires_at,
invitation.personal_code,
account.name,
account.email
from
medreport.invitations as invitation
join medreport.accounts as account on invitation.account_id = account.id
where
account.slug = account_slug;
end;
$$ language plpgsql;
grant execute on function medreport.get_account_invitations(text) to authenticated, service_role;
drop policy if exists invitations_read_self on medreport.invitations;
drop policy if exists invitations_create_self on medreport.invitations;
drop policy if exists invitations_update on medreport.invitations;
drop policy if exists invitations_delete on medreport.invitations;
drop policy if exists super_admins_access_invitations on medreport.invitations;
create policy invitations_read_self on medreport.invitations for
select
to authenticated using (medreport.has_role_on_account (account_id));
create policy invitations_create_self on medreport.invitations for insert to authenticated
with
check (
medreport.is_set ('enable_team_accounts')
and medreport.has_permission (
(
select
auth.uid ()
),
account_id,
'invites.manage'::medreport.app_permissions
)
and (medreport.has_more_elevated_role (
(
select
auth.uid ()
),
account_id,
role
) or medreport.has_same_role_hierarchy_level(
(
select
auth.uid ()
),
account_id,
role
))
);
create policy invitations_update on medreport.invitations
for update
to authenticated using (
medreport.has_permission (
(
select
auth.uid ()
),
account_id,
'invites.manage'::medreport.app_permissions
)
and medreport.has_more_elevated_role (
(
select
auth.uid ()
),
account_id,
role
)
)
with
check (
medreport.has_permission (
(
select
auth.uid ()
),
account_id,
'invites.manage'::medreport.app_permissions
)
and medreport.has_more_elevated_role (
(
select
auth.uid ()
),
account_id,
role
)
);
create policy invitations_delete on medreport.invitations for delete to authenticated using (
medreport.has_role_on_account (account_id)
and medreport.has_permission (
(
select
auth.uid ()
),
account_id,
'invites.manage'::medreport.app_permissions
)
);
create policy super_admins_access_invitations
on medreport.invitations
as permissive
for select
to authenticated
using (medreport.is_super_admin());
INSERT INTO medreport.config DEFAULT VALUES;
DROP FUNCTION public.get_invitations_with_account_ids(
company_id uuid,
personal_codes text[]
);
ALTER TABLE "medreport"."invitations" ADD COLUMN IF NOT EXISTS personal_code text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'accounts_personal_code_unique'
) THEN
ALTER TABLE medreport.accounts
ADD CONSTRAINT accounts_personal_code_unique UNIQUE (personal_code);
END IF;
END$$;
ALTER TABLE medreport.invitations
ALTER COLUMN personal_code TYPE text;
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint
WHERE conname = 'invitations_personal_code_unique'
) THEN
ALTER TABLE medreport.invitations
ADD CONSTRAINT invitations_personal_code_unique UNIQUE (personal_code);
END IF;
END$$;
create or replace function medreport.get_invitations_with_account_ids(
company_id uuid,
personal_codes text[]
)
returns table (
invite_token text,
personal_code text,
account_id uuid
)
language sql
as $$
select
i.invite_token,
i.personal_code,
a.id as account_id
from medreport.invitations i
join medreport.accounts a on a.personal_code = i.personal_code
where i.account_id = company_id
and i.personal_code = any(personal_codes);
$$;
grant
execute on function medreport.get_invitations_with_account_ids (uuid, text[]) to authenticated,
service_role;
GRANT USAGE, SELECT ON SEQUENCE medreport.invitations_id_seq TO authenticated;
DROP FUNCTION IF EXISTS public.has_permission(uuid, uuid, medreport.app_permissions);

View File

@@ -0,0 +1,67 @@
DROP TRIGGER IF EXISTS add_current_user_to_new_account ON medreport.accounts;
create or replace function medreport.create_team_account (
account_name text,
new_personal_code text
) returns medreport.accounts
security definer
set search_path = ''
as $$
declare
existing_account medreport.accounts;
current_user uuid := (select auth.uid())::uuid;
new_account medreport.accounts;
begin
if not medreport.is_set('enable_team_accounts') then
raise exception 'Team accounts are not enabled';
end if;
insert into medreport.accounts(
name,
is_personal_account)
values (
account_name,
false)
returning
* into new_account;
-- Try to find existing account
select *
into existing_account
from medreport.accounts
where personal_code = new_personal_code
limit 1;
-- If not found, fail
if not found then
raise exception 'No account found with personal_code = %', new_personal_code;
end if;
-- Insert membership
insert into medreport.accounts_memberships (
user_id,
account_id,
account_role,
created_by,
updated_by,
created_at,
updated_at,
has_seen_confirmation
)
values (
existing_account.id,
new_account.id,
'owner',
null,
null,
now(),
now(),
false
)
on conflict do nothing;
return new_account;
end;
$$ language plpgsql;
grant execute on function medreport.create_team_account (text, text) to authenticated, service_role;

View File

@@ -0,0 +1,9 @@
create trigger "trigger_invitation_email" after insert
on "medreport"."invitations" for each row
execute function "supabase_functions"."http_request"(
'http://host.docker.internal:3000/api/db/webhook',
'POST',
'{"Content-Type":"application/json", "X-Supabase-Event-Signature":"WEBHOOKSECRET"}',
'{}',
'1000'
);