Skip to content

Invitation email sending (only in development and only when RESEND_API_KEY is set now) #1513

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env.template
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ NEXT_PUBLIC_SUPABASE_URL="http://localhost:54321"
OPENAI_API_KEY=""
POSTGRES_URL=""
POSTGRES_URL_NON_POOLING=""
RESEND_API_KEY=""
SENTRY_AUTH_TOKEN=""
SENTRY_DSN=""
SENTRY_ORG=""
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { createClient } from '@/libs/db/server'
import { revalidatePath } from 'next/cache'
import * as v from 'valibot'
import { sendInvitationEmail } from './sendInvitationEmail'

// Define schema for form data validation
const inviteFormSchema = v.object({
Expand All @@ -18,6 +19,7 @@ const invitationResultSchema = v.union([
v.object({
success: v.literal(true),
error: v.null(),
invitation_token: v.string(),
}),
v.object({
success: v.literal(false),
Expand Down Expand Up @@ -69,7 +71,25 @@ export const inviteMember = async (formData: FormData) => {
} as const
}

// TODO: Send email to user
// Type narrowing for result.output
if (!result.output.success) {
return result.output
}

// Send invitation email
const emailResult = await sendInvitationEmail({
email,
organizationId,
invitationToken: result.output.invitation_token,
})

if (!emailResult.success) {
return {
success: false,
error: emailResult.error,
} as const
}

revalidatePath(
`/app/organizations/${organizationId}/settings/members`,
'page',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { createClient } from '@/libs/db/server'
import { Resend } from 'resend'

// Email template component
const InvitationEmail = ({
organizationName,
invitationLink,
}: { organizationName: string; invitationLink: string }) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>Organization Invitation</title>
</head>
<body>
<h1>You've been invited to join ${organizationName}</h1>
<p>You have been invited to join ${organizationName} on Liam.</p>
<p>Click the link below to accept the invitation:</p>
<a href="${invitationLink}">Accept Invitation</a>
<p>If you did not expect this invitation, you can safely ignore this email.</p>
</body>
</html>
`
}

type SendInvitationEmailParams = {
email: string
organizationId: string
invitationToken: string
}

export const sendInvitationEmail = async ({
email,
organizationId,
invitationToken,
}: SendInvitationEmailParams) => {
// TODO: Enable email sending in preview/production
// For now, we don't want to send emails in preview/production
if (process.env.NODE_ENV !== 'development' || !process.env.RESEND_API_KEY) {
return { success: true, error: null }
}

const supabase = await createClient()

// Get organization name for the email
const { data: orgData, error: orgError } = await supabase
.from('organizations')
.select('name')
.eq('id', organizationId)
.single()

if (orgError) {
console.error('Error fetching organization:', orgError)
return {
success: false,
error: 'Failed to fetch organization details.',
} as const
}

let baseUrl: string | undefined = undefined
switch (process.env.NEXT_PUBLIC_ENV_NAME) {
case 'production':
baseUrl = process.env.NEXT_PUBLIC_BASE_URL // NEXT_PUBLIC_BASE_URL includes "https://"
break
case 'preview':
baseUrl = `https://${process.env.VERCEL_BRANCH_URL}` // VERCEL_BRANCH_URL does not include "https://"
break
default:
baseUrl = 'http://localhost:3001'
break
}

// Construct invitation link
const invitationLink = `${baseUrl || ''}/app/invitations/tokens/${invitationToken}`

// Send email
const resend = new Resend(process.env.RESEND_API_KEY)
const { error: emailError } = await resend.emails.send({
from: process.env.EMAIL_FROM_ADDRESS || '[email protected]',
to: email,
subject: `Invitation to join ${orgData.name} on Liam`,
html: InvitationEmail({
organizationName: orgData.name,
invitationLink,
}),
})

if (emailError) {
console.error('Error sending invitation email:', emailError)
return {
success: false,
error: 'Failed to send invitation email.',
} as const
}

return { success: true, error: null } as const
}
1 change: 1 addition & 0 deletions frontend/apps/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"next": "15.3.1",
"react": "18.3.1",
"react-dom": "18",
"resend": "4.4.1",
"ts-pattern": "5.7.0",
"valibot": "1.0.0",
"yaml": "2.7.1"
Expand Down
26 changes: 21 additions & 5 deletions frontend/packages/db/schema/schema.sql
Original file line number Diff line number Diff line change
Expand Up @@ -246,6 +246,7 @@ declare
v_is_member boolean;
v_invite_by_user_id uuid;
v_existing_invite_id uuid;
v_new_token uuid;
v_result jsonb;
begin
-- Start transaction
Expand All @@ -261,6 +262,7 @@ begin
) then
v_result := jsonb_build_object(
'success', false,
'invitation_token', null,
'error', 'inviter user does not exist'
);
return v_result;
Expand All @@ -278,11 +280,14 @@ begin
if v_is_member then
v_result := jsonb_build_object(
'success', false,
'invitation_token', null,
'error', 'this user is already a member of the organization'
);
return v_result;
end if;

v_new_token := gen_random_uuid();

-- Check if invitation already exists
select id into v_existing_invite_id
from invitations
Expand All @@ -296,27 +301,37 @@ begin
set invited_at = current_timestamp,
expired_at = current_timestamp + interval '7 days',
invite_by_user_id = v_invite_by_user_id,
token = gen_random_uuid()
token = v_new_token
where id = v_existing_invite_id;

v_result := jsonb_build_object('success', true, 'error', null);
v_result := jsonb_build_object(
'success', true,
'invitation_token', v_new_token,
'error', null
);
else
-- Create new invitation
insert into invitations (
organization_id,
email,
invited_at,
expired_at,
invite_by_user_id
invite_by_user_id,
token
) values (
p_organization_id,
lower(p_email),
current_timestamp,
current_timestamp + interval '7 days',
v_invite_by_user_id
v_invite_by_user_id,
v_new_token
);

v_result := jsonb_build_object('success', true, 'error', null);
v_result := jsonb_build_object(
'success', true,
'invitation_token', v_new_token,
'error', null
);
end if;

-- Commit transaction
Expand All @@ -325,6 +340,7 @@ begin
-- Handle any errors
v_result := jsonb_build_object(
'success', false,
'invitation_token', null,
'error', sqlerrm
);
return v_result;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- This empty .sql file is required because a migration with this filename was already applied in production.
-- It will no longer be needed if the production database is ever reset.
Comment on lines +1 to +2
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, I want to squash migrations before first release!

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with squashing. I think it's fine to squash all the files, not just the empty ones! :)

Original file line number Diff line number Diff line change
@@ -1,96 +1,2 @@
-- Function to handle organization member invitations atomically
create or replace function invite_organization_member(
p_email text,
p_organization_id uuid
) returns jsonb as $$
declare
v_is_member boolean;
v_invite_by_user_id uuid;
v_existing_invite_id uuid;
v_result jsonb;
begin
-- Start transaction
begin
v_invite_by_user_id := auth.uid();

-- Check inviter is a valid user
if not exists (
select 1
from organization_members om
where om.user_id = v_invite_by_user_id
and om.organization_id = p_organization_id
) then
v_result := jsonb_build_object(
'success', false,
'error', 'inviter user does not exist'
);
return v_result;
end if;

-- Check if user is already a member
select exists(
select 1
from organization_members om
join users u on om.user_id = u.id
where om.organization_id = p_organization_id
and lower(u.email) = lower(p_email)
) into v_is_member;

if v_is_member then
v_result := jsonb_build_object(
'success', false,
'error', 'this user is already a member of the organization'
);
return v_result;
end if;

-- Check if invitation already exists
select id into v_existing_invite_id
from invitations
where organization_id = p_organization_id
and lower(email) = lower(p_email)
limit 1;

-- If invitation exists, update it
if v_existing_invite_id is not null then
update invitations
set invited_at = current_timestamp,
expired_at = current_timestamp + interval '7 days',
invite_by_user_id = v_invite_by_user_id,
token = gen_random_uuid()
where id = v_existing_invite_id;

v_result := jsonb_build_object('success', true, 'error', null);
else
-- Create new invitation
insert into invitations (
organization_id,
email,
invited_at,
expired_at,
invite_by_user_id
) values (
p_organization_id,
lower(p_email),
current_timestamp,
current_timestamp + interval '7 days',
v_invite_by_user_id
);

v_result := jsonb_build_object('success', true, 'error', null);
end if;

-- Commit transaction
return v_result;
exception when others then
-- Handle any errors
v_result := jsonb_build_object(
'success', false,
'error', sqlerrm
);
return v_result;
end;
end;
$$ language plpgsql;

revoke all on function invite_organization_member(text, uuid) from anon;
-- This empty .sql file is required because a migration with this filename was already applied in production.
-- It will no longer be needed if the production database is ever reset.
Loading
Loading