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:
3
.env
3
.env
@@ -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
|
||||||
|
|||||||
@@ -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'}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
|
|||||||
280
supabase/migrations/20250730091700_fix_company_invitations.sql
Normal file
280
supabase/migrations/20250730091700_fix_company_invitations.sql
Normal 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);
|
||||||
67
supabase/migrations/20250730165200_create_team_account.sql
Normal file
67
supabase/migrations/20250730165200_create_team_account.sql
Normal 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;
|
||||||
@@ -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'
|
||||||
|
);
|
||||||
Reference in New Issue
Block a user