diff --git a/.env b/.env
index 8b4ef54..04c866d 100644
--- a/.env
+++ b/.env
@@ -42,7 +42,10 @@ NEXT_PUBLIC_LANGUAGE_PRIORITY=application
NEXT_PUBLIC_ENABLE_NOTIFICATIONS=true
NEXT_PUBLIC_REALTIME_NOTIFICATIONS=true
+WEBHOOK_SENDER_PROVIDER=postgres
+
# MAILER DEV
+MAILER_PROVIDER=nodemailer
EMAIL_SENDER=info@medreport.ee
EMAIL_USER= # refer to your email provider's documentation
EMAIL_PASSWORD= # refer to your email provider's documentation
diff --git a/packages/features/accounts/src/components/personal-account-dropdown.tsx b/packages/features/accounts/src/components/personal-account-dropdown.tsx
index 98812d5..933d099 100644
--- a/packages/features/accounts/src/components/personal-account-dropdown.tsx
+++ b/packages/features/accounts/src/components/personal-account-dropdown.tsx
@@ -163,9 +163,9 @@ export function PersonalAccountDropdown({
-
-
0}>
+
+
{
return (
-
+
-
-
-
+
+
+ );
+ }}
+ />
+
+ {
+ return (
+
+
+
+
+
+
+
+
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 e648b0b..7903186 100644
--- a/packages/features/admin/src/lib/server/admin-server-actions.ts
+++ b/packages/features/admin/src/lib/server/admin-server-actions.ts
@@ -239,7 +239,7 @@ export const resetPasswordAction = adminAction(
);
export const createCompanyAccountAction = enhanceAction(
- async ({ name }, user) => {
+ async ({ name, ownerPersonalCode }, user) => {
const logger = await getLogger();
const client = getSupabaseServerClient();
const service = createCreateCompanyAccountService(client);
@@ -254,7 +254,7 @@ export const createCompanyAccountAction = enhanceAction(
const { data, error } = await service.createNewOrganizationAccount({
name,
- userId: user.id,
+ ownerPersonalCode,
});
if (error) {
@@ -266,8 +266,7 @@ export const createCompanyAccountAction = enhanceAction(
}
logger.info(ctx, `Company account created`);
-
- redirect(`/home/${data.slug}/settings`);
+ redirect(`/admin/accounts/${data.id}`);
},
{
schema: CreateCompanySchema,
diff --git a/packages/features/admin/src/lib/server/schema/create-company.schema.ts b/packages/features/admin/src/lib/server/schema/create-company.schema.ts
index 4fc0a04..2fae4fe 100644
--- a/packages/features/admin/src/lib/server/schema/create-company.schema.ts
+++ b/packages/features/admin/src/lib/server/schema/create-company.schema.ts
@@ -1,5 +1,19 @@
+import Isikukood from 'isikukood';
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
* @description Array of reserved names for team accounts
@@ -46,7 +60,7 @@ export const CompanyNameSchema = z
*/
export const CreateCompanySchema = z.object({
name: CompanyNameSchema,
+ ownerPersonalCode: personalCodeSchema,
});
export type CreateCompanySchemaType = z.infer;
-
diff --git a/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts b/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts
index 566fe8c..0a9fe98 100644
--- a/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts
+++ b/packages/features/admin/src/lib/server/services/admin-create-company-account.service.ts
@@ -16,7 +16,10 @@ class CreateTeamAccountService {
constructor(private readonly client: SupabaseClient) {}
- async createNewOrganizationAccount(params: { name: string; userId: string }) {
+ async createNewOrganizationAccount(params: {
+ name: string;
+ ownerPersonalCode: string;
+ }) {
const logger = await getLogger();
const ctx = { ...params, namespace: this.namespace };
@@ -26,12 +29,13 @@ class CreateTeamAccountService {
.schema('medreport')
.rpc('create_team_account', {
account_name: params.name,
+ new_personal_code: params.ownerPersonalCode,
});
if (error) {
logger.error(
{
- error,
+ error: error.message,
...ctx,
},
`Error creating company account`,
diff --git a/packages/features/auth/src/components/multi-factor-challenge-container.tsx b/packages/features/auth/src/components/multi-factor-challenge-container.tsx
index 17f30f4..a9a7fdd 100644
--- a/packages/features/auth/src/components/multi-factor-challenge-container.tsx
+++ b/packages/features/auth/src/components/multi-factor-challenge-container.tsx
@@ -206,6 +206,10 @@ function useVerifyMFAChallenge({ onSuccess }: { onSuccess: () => void }) {
});
if (response.error) {
+ console.warn(
+ { error: response.error.message },
+ 'Failed to verify MFA challenge',
+ );
throw response.error;
}
diff --git a/packages/features/notifications/src/server/notifications.service.ts b/packages/features/notifications/src/server/notifications.service.ts
index 3bb8d4c..265a45b 100644
--- a/packages/features/notifications/src/server/notifications.service.ts
+++ b/packages/features/notifications/src/server/notifications.service.ts
@@ -2,6 +2,7 @@ import 'server-only';
import { SupabaseClient } from '@supabase/supabase-js';
+import { getLogger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
type Notification = Database['medreport']['Tables']['notifications'];
@@ -14,12 +15,17 @@ class NotificationsService {
constructor(private readonly client: SupabaseClient) {}
async createNotification(params: Notification['Insert']) {
+ const logger = await getLogger();
const { error } = await this.client
.schema('medreport')
.from('notifications')
.insert(params);
if (error) {
+ logger.error(
+ { ...params },
+ `Could not create notification: ${error.message}`,
+ );
throw error;
}
}
diff --git a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts
index 74d69ce..4f11f93 100644
--- a/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts
+++ b/packages/features/team-accounts/src/server/actions/team-invitations-server-actions.ts
@@ -54,8 +54,9 @@ export const createInvitationsAction = enhanceAction(
);
}
- const { data: invitations, error: invitationError } =
- await serviceClient.rpc('get_invitations_with_account_ids', {
+ const { data: invitations, error: invitationError } = await serviceClient
+ .schema('medreport')
+ .rpc('get_invitations_with_account_ids', {
company_id: company[0].id,
personal_codes: personalCodes,
});
diff --git a/packages/features/team-accounts/src/server/services/account-invitations.service.ts b/packages/features/team-accounts/src/server/services/account-invitations.service.ts
index a118f19..c707f84 100644
--- a/packages/features/team-accounts/src/server/services/account-invitations.service.ts
+++ b/packages/features/team-accounts/src/server/services/account-invitations.service.ts
@@ -51,7 +51,10 @@ class AccountInvitationsService {
});
if (error) {
- logger.error(ctx, `Failed to remove invitation`);
+ logger.error(
+ { ...ctx, error: error.message },
+ `Failed to remove invitation`,
+ );
throw error;
}
diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts
index 48906af..cec67c4 100644
--- a/packages/supabase/src/database.types.ts
+++ b/packages/supabase/src/database.types.ts
@@ -218,10 +218,24 @@ export type Database = {
{
foreignKeyName: "account_params_account_id_fkey"
columns: ["account_id"]
- isOneToOne: true
+ isOneToOne: false
referencedRelation: "accounts"
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: {
@@ -316,7 +330,13 @@ export type Database = {
user_id?: string
}
Relationships: [
-
+ {
+ foreignKeyName: "accounts_memberships_account_id_fkey"
+ columns: ["account_id"]
+ isOneToOne: false
+ referencedRelation: "accounts"
+ referencedColumns: ["id"]
+ },
{
foreignKeyName: "accounts_memberships_account_id_fkey"
columns: ["account_id"]
@@ -932,6 +952,7 @@ export type Database = {
id: number
invite_token: string
invited_by: string
+ personal_code: string | null
role: string
updated_at: string
}
@@ -943,6 +964,7 @@ export type Database = {
id?: number
invite_token: string
invited_by: string
+ personal_code?: string | null
role: string
updated_at?: string
}
@@ -954,6 +976,7 @@ export type Database = {
id?: number
invite_token?: string
invited_by?: string
+ personal_code?: string | null
role?: string
updated_at?: string
}
@@ -1543,7 +1566,7 @@ export type Database = {
add_invitations_to_account: {
Args: {
account_slug: string
- invitations: Database["public"]["CompositeTypes"]["invitation"][]
+ invitations: Database["medreport"]["CompositeTypes"]["invitation"][]
}
Returns: Database["medreport"]["Tables"]["invitations"]["Row"][]
}
@@ -1561,6 +1584,7 @@ export type Database = {
id: number
invite_token: string
invited_by: string
+ personal_code: string | null
role: string
updated_at: string
}
@@ -1577,7 +1601,9 @@ export type Database = {
Returns: Json
}
create_team_account: {
- Args: { account_name: string }
+ Args:
+ | { account_name: string }
+ | { account_name: string; new_personal_code: string }
Returns: {
city: string | null
created_at: string | null
@@ -1609,6 +1635,7 @@ export type Database = {
created_at: string
updated_at: string
expires_at: string
+ personal_code: string
inviter_name: string
inviter_email: string
}[]
@@ -1633,6 +1660,14 @@ export type Database = {
Args: Record
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: {
Args: { p_id: string }
Returns: Json
@@ -1846,7 +1881,11 @@ export type Database = {
| "paused"
}
CompositeTypes: {
- [_ in never]: never
+ invitation: {
+ email: string | null
+ role: string | null
+ personal_code: string | null
+ }
}
}
public: {
@@ -7531,14 +7570,6 @@ export type Database = {
[_ in never]: never
}
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: {
Args: {
user_id: string
diff --git a/public/locales/et/teams.json b/public/locales/et/teams.json
index 9500fd3..3953e7f 100644
--- a/public/locales/et/teams.json
+++ b/public/locales/et/teams.json
@@ -160,5 +160,6 @@
"leaveTeamInputDescription": "By leaving the company, you will no longer have access to it.",
"reservedNameError": "This name is reserved. Please choose a different one.",
"specialCharactersError": "This name cannot contain special characters. Please choose a different one.",
- "personalCode": "Isikukood"
+ "personalCode": "Isikukood",
+ "teamOwnerPersonalCodeLabel": "Omaniku isikukood"
}
diff --git a/supabase/migrations/20250730091700_fix_company_invitations.sql b/supabase/migrations/20250730091700_fix_company_invitations.sql
new file mode 100644
index 0000000..ffaebb0
--- /dev/null
+++ b/supabase/migrations/20250730091700_fix_company_invitations.sql
@@ -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);
\ No newline at end of file
diff --git a/supabase/migrations/20250730165200_create_team_account.sql b/supabase/migrations/20250730165200_create_team_account.sql
new file mode 100644
index 0000000..0eec9b8
--- /dev/null
+++ b/supabase/migrations/20250730165200_create_team_account.sql
@@ -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;
diff --git a/supabase/migrations/20253107112400_invitation_email_webhook.sql b/supabase/migrations/20253107112400_invitation_email_webhook.sql
new file mode 100644
index 0000000..b8d461c
--- /dev/null
+++ b/supabase/migrations/20253107112400_invitation_email_webhook.sql
@@ -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'
+);
\ No newline at end of file