snapshot of https://github.com/marmelab/atomic-crm.git (main, 2026-09-18; blobs > 131072B omitted)
Some checks failed
✅ Check / 🔬 ESLint (push) Failing after 21s
✅ Check / 🏷️ Typecheck (push) Failing after 11s
✅ Check / 🔎 Test (push) Failing after 11s
✅ Check / e2e-test (push) Failing after 11s
✅ Check / 🔨 Build (push) Failing after 11s
🚀 Deploy / 🚀 Deploy (doc) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (demo) (push) Failing after 12s
🚀 Deploy / 🚀 Deploy (supabase) (push) Failing after 18s

This commit is contained in:
upstream-mirror
2026-09-18 01:48:50 +05:00
commit 93ecc1a6f8
787 changed files with 75240 additions and 0 deletions

168
supabase/config.e2e.toml Normal file
View File

@@ -0,0 +1,168 @@
# Separate Supabase instance for e2e testing.
# Uses a different project_id (separate Docker containers) and a shifted port
# range (+20) so it can run in parallel with the regular dev instance.
project_id = "atomic-crm-e2e"
[api]
enabled = true
# Port to use for the API URL.
port = 54341
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54342
# Port used by db diff command to initialize the shadow database.
shadow_port = 54340
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54349
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54343
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54344
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:5175"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:5175/auth-callback.html"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Development signing keys
signing_keys_path = "./signing_keys.json"
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
[auth.email.template.invite]
subject = "You've been invited to Atomic CRM"
content_path = "./supabase/templates/invite.html"
[auth.email.template.recovery]
subject = "Reset your Atomic CRM Password"
content_path = "./supabase/templates/recovery.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# 4152127777 = "123456"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
[analytics]
enabled = false
port = 54347
vector_port = 54348
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
[functions.merge_contacts]
verify_jwt = false
[functions.delete_note_attachments]
verify_jwt = false
[functions.postmark]
verify_jwt = false
[functions.update_password]
verify_jwt = false
[functions.users]
verify_jwt = false

174
supabase/config.toml Normal file
View File

@@ -0,0 +1,174 @@
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "atomic-crm-demo"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. public and storage are always included.
schemas = ["public", "storage", "graphql_public"]
# Extra schemas to add to the search_path of every request. public is always included.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 15
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv6)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://localhost:5173"
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://localhost:5173/auth-callback.html"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Development signing keys
signing_keys_path = "./signing_keys.json"
[auth.oauth_server]
enabled = true
authorization_url_path = "/oauth/consent"
allow_dynamic_registration = true
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
[auth.email.template.invite]
subject = "You've been invited to Atomic CRM"
content_path = "./supabase/templates/invite.html"
[auth.email.template.recovery]
subject = "Reset your Atomic CRM Password"
content_path = "./supabase/templates/recovery.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = true
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }} ."
# Use pre-defined map of phone number to OTP for testing.
[auth.sms.test_otp]
# 4152127777 = "123456"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin`, `notion`, `twitch`,
# `twitter`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth redirectUrl.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
[analytics]
enabled = false
port = 54327
vector_port = 54328
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
[functions.merge_contacts]
verify_jwt = false
[functions.delete_note_attachments]
verify_jwt = false
[functions.postmark]
verify_jwt = false
[functions.update_password]
verify_jwt = false
[functions.users]
verify_jwt = false
[functions.mcp]
verify_jwt = false

15
supabase/functions/.env Normal file
View File

@@ -0,0 +1,15 @@
POSTMARK_WEBHOOK_USER=testuser
POSTMARK_WEBHOOK_PASSWORD=testpwd
VITE_INBOUND_EMAIL=2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com
# IPs can be found at https://postmarkapp.com/support/article/800-ips-for-firewalls#webhooks
# 172.18.0.1, 172.19.0.1 and 172.20.0.1 are for local dev only
# 192.168.65.1 is for Docker Desktop on Mac
POSTMARK_WEBHOOK_AUTHORIZED_IPS=3.134.147.250,50.31.156.6,50.31.156.77,18.217.206.57,172.18.0.1,172.19.0.1,192.168.65.1,172.20.0.1
SB_JWT_ISSUER=http://127.0.0.1:54321/auth/v1
SB_PUBLISHABLE_KEY=sb_publishable_ACJWlzQHlZjBrEguHvfOxg_3BJgxAaH
# Public URL of the CRM app — used by the task-list MCP App to link
# contact names back to the CRM. Leave empty to render names as plain text.
CRM_BASE_URL=http://localhost:5173

View File

@@ -0,0 +1,80 @@
// Based on https://github.com/supabase/supabase/blob/master/examples/edge-functions/supabase/functions/_shared/jwt/default.ts
import * as jose from "jsr:@panva/jose@6";
import { createClient, type User } from "jsr:@supabase/supabase-js@2";
import { createErrorResponse } from "./utils.ts";
const SUPABASE_JWT_ISSUER =
Deno.env.get("SB_JWT_ISSUER") ?? Deno.env.get("SUPABASE_URL") + "/auth/v1";
const SUPABASE_JWT_KEYS = jose.createRemoteJWKSet(
new URL(Deno.env.get("SUPABASE_URL")! + "/auth/v1/.well-known/jwks.json"),
);
function getAuthToken(req: Request) {
const authHeader = req.headers.get("authorization");
if (!authHeader) {
throw new Error("Missing authorization header");
}
const [bearer, token] = authHeader.split(" ");
if (bearer !== "Bearer") {
throw new Error(`Auth header is not 'Bearer {token}'`);
}
return token;
}
function verifySupabaseJWT(jwt: string) {
return jose.jwtVerify(jwt, SUPABASE_JWT_KEYS, {
issuer: SUPABASE_JWT_ISSUER,
});
}
/**
* Validates the Authorization header to ensure that a user is authenticated.
*/
export const AuthMiddleware = async (
req: Request,
next: (req: Request) => Promise<Response>,
) => {
if (req.method === "OPTIONS") return await next(req);
try {
const token = getAuthToken(req);
const isValidJWT = await verifySupabaseJWT(token);
if (isValidJWT) return await next(req);
return createErrorResponse(401, "Invalid authentication");
} catch (e) {
return createErrorResponse(401, e?.toString() || "Unauthorized");
}
};
/**
* Get the authenticated user using the authorization header.
* User will be undefined for OPTIONS requests.
*/
export const UserMiddleware = async (
req: Request,
next: (req: Request, user?: User) => Promise<Response>,
) => {
if (req.method === "OPTIONS") return await next(req);
try {
const authHeader = req.headers.get("Authorization")!;
const localClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
Deno.env.get("SB_PUBLISHABLE_KEY") ?? "",
{ global: { headers: { Authorization: authHeader } } },
);
const { data, error: authError } = await localClient.auth.getUser();
if (!data?.user || authError) {
return createErrorResponse(401, "Unauthorized");
}
return next(req, data.user);
} catch (err) {
return createErrorResponse(401, err?.toString() || "Unauthorized");
}
};

View File

@@ -0,0 +1,20 @@
export const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type",
"Access-Control-Allow-Methods": "POST, PATCH, DELETE",
};
/**
* Handle OPTIONS requests for CORS preflight.
*/
export function OptionsMiddleware(
req: Request,
next: (req: Request) => Promise<Response>,
) {
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
return next(req);
}

View File

@@ -0,0 +1,187 @@
import type {
DatabaseConnection,
Driver,
QueryResult,
} from "https://esm.sh/kysely@0.27.2";
import {
CompiledQuery,
Kysely,
PostgresAdapter,
PostgresIntrospector,
PostgresQueryCompiler,
type Generated,
} from "https://esm.sh/kysely@0.27.2";
export { CompiledQuery };
import type { PoolClient } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
// Database schema types
export interface ContactsTable {
id: Generated<number>;
first_name: string | null;
last_name: string | null;
gender: string | null;
title: string | null;
email_jsonb: unknown | null; // JSONB array
phone_jsonb: unknown | null; // JSONB array
background: string | null;
avatar: unknown | null; // JSONB
first_seen: Date | null;
last_seen: Date | null;
has_newsletter: boolean | null;
status: string | null;
tags: number[] | null;
company_id: number | null;
sales_id: number | null;
linkedin_url: string | null;
}
interface TasksTable {
id: Generated<number>;
contact_id: number;
type: string | null;
text: string | null;
due_date: Date;
done_date: Date | null;
sales_id: number | null;
}
interface ContactNotesTable {
id: Generated<number>;
contact_id: number;
text: string | null;
date: Date | null;
sales_id: number | null;
status: string | null;
attachments: unknown[] | null; // JSONB array
}
interface DealsTable {
id: Generated<number>;
name: string;
company_id: number | null;
contact_ids: number[];
category: string | null;
stage: string;
description: string | null;
amount: number | null;
created_at: Date;
updated_at: Date;
archived_at: Date | null;
expected_closing_date: Date | null;
sales_id: number | null;
index: number | null;
}
interface Database {
contacts: ContactsTable;
tasks: TasksTable;
contact_notes: ContactNotesTable;
deals: DealsTable;
}
// Deno Postgres Driver for Kysely
class DenoPostgresDriver implements Driver {
private pool: Pool;
private connections = new WeakMap<PoolClient, DatabaseConnection>();
constructor(pool: Pool) {
this.pool = pool;
}
async init(): Promise<void> {
// Connection pool is already initialized
}
async acquireConnection(): Promise<DatabaseConnection> {
const client = await this.pool.connect();
let connection = this.connections.get(client);
if (!connection) {
connection = new DenoPostgresConnection(client);
this.connections.set(client, connection);
}
return connection;
}
async beginTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("begin"));
}
async commitTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("commit"));
}
async rollbackTransaction(connection: DatabaseConnection): Promise<void> {
await connection.executeQuery(CompiledQuery.raw("rollback"));
}
async releaseConnection(connection: DatabaseConnection): Promise<void> {
(connection as DenoPostgresConnection).release();
}
async destroy(): Promise<void> {
await this.pool.end();
}
}
class DenoPostgresConnection implements DatabaseConnection {
private client: PoolClient;
constructor(client: PoolClient) {
this.client = client;
}
async executeQuery<O>(compiledQuery: CompiledQuery): Promise<QueryResult<O>> {
const result = await this.client.queryObject<O>({
text: compiledQuery.sql,
args: compiledQuery.parameters as unknown[],
});
if (
result.command === "INSERT" ||
result.command === "UPDATE" ||
result.command === "DELETE"
) {
return {
numAffectedRows: BigInt(result.rowCount ?? 0),
rows: result.rows ?? [],
};
}
return {
rows: result.rows ?? [],
};
}
streamQuery<O>(
_compiledQuery: CompiledQuery,
_chunkSize?: number,
): AsyncIterableIterator<QueryResult<O>> {
throw new Error("Deno Postgres driver does not support streaming");
}
release() {
this.client.release();
}
}
// Create connection pool
// Use SUPABASE_DB_URL if available (production), otherwise fall back to local dev connection string
const connectionString =
Deno.env.get("SUPABASE_DB_URL") ||
"postgresql://postgres:postgres@db:5432/postgres";
const pool = new Pool(connectionString, 1); // Single connection for edge functions
// Create and export Kysely instance
export const db = new Kysely<Database>({
dialect: {
createAdapter: () => new PostgresAdapter(),
createDriver: () => new DenoPostgresDriver(pool),
createIntrospector: (db: Kysely<any>) => new PostgresIntrospector(db),
createQueryCompiler: () => new PostgresQueryCompiler(),
},
});

View File

@@ -0,0 +1,15 @@
import { type User } from "jsr:@supabase/supabase-js@2";
import { supabaseAdmin } from "./supabaseAdmin.ts";
/**
* Get the sale associated to the provided user.
*/
export const getUserSale = async (user: User) => {
return (
await supabaseAdmin
.from("sales")
.select("*")
.eq("user_id", user.id)
.single()
)?.data;
};

View File

@@ -0,0 +1,15 @@
import type { SupabaseClient } from "jsr:@supabase/supabase-js@2";
import { createClient } from "jsr:@supabase/supabase-js@2";
export const supabaseAdmin: SupabaseClient = createClient(
Deno.env.get("SUPABASE_URL") ?? "",
// TODO - When Supabase finally provides the project's secret key to the functions, we'll need to use it here instead of the service role key.
// See https://supabase.com/docs/guides/functions/auth#get-api-details
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY") ?? "",
{
auth: {
autoRefreshToken: false,
persistSession: false,
},
},
);

View File

@@ -0,0 +1,12 @@
import { corsHeaders } from "./cors.ts";
export function createErrorResponse(
status: number,
message: string,
custom: Record<string, unknown> = {},
) {
return new Response(JSON.stringify({ status, message, ...custom }), {
headers: { "Content-Type": "application/json", ...corsHeaders },
status,
});
}

View File

@@ -0,0 +1,158 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { AuthMiddleware } from "../_shared/authentication.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
const ATTACHMENTS_BUCKET =
Deno.env.get("VITE_ATTACHMENTS_BUCKET") || "attachments";
type NoteAttachment = {
path?: string | null;
src?: string | null;
};
type NoteRecord = {
id?: number | string | null;
attachments?: NoteAttachment[] | null;
};
type WebhookPayload = {
type?: string | null;
old_record?: NoteRecord | null;
record?: NoteRecord | null;
};
const deleteNoteAttachments = async (req: Request) => {
if (req.method !== "POST") {
return jsonResponse({ error: "Method Not Allowed" }, 405);
}
const payload = (await req.json()) as WebhookPayload;
const paths = getPathsToDelete(payload);
if (paths.length === 0) {
return jsonResponse({
status: "skipped",
reason: "no_paths_to_delete",
});
}
const { error } = await supabaseAdmin.storage
.from(ATTACHMENTS_BUCKET)
.remove(paths);
if (error) {
console.error("Failed to delete note attachments", {
type: payload.type ?? null,
paths,
error,
});
return jsonResponse({ error: "Failed to delete note attachments" }, 500);
}
return jsonResponse({
status: "ok",
});
};
Deno.serve(async (req: Request) =>
AuthMiddleware(req, async (req: Request) => deleteNoteAttachments(req)),
);
const getPathsToDelete = (payload: WebhookPayload): string[] => {
const oldPaths = extractAttachmentPaths(payload.old_record?.attachments);
const newPaths = extractAttachmentPaths(payload.record?.attachments);
if (payload.type === "UPDATE") {
const newPathsSet = new Set(newPaths);
return oldPaths.filter((path) => !newPathsSet.has(path));
}
if (payload.type === "DELETE") {
return oldPaths;
}
return [];
};
const extractAttachmentPaths = (
attachments?: NoteAttachment[] | null,
): string[] => {
const paths = attachments
?.map((attachment) => extractAttachmentPath(attachment))
.filter((path): path is string => path != null && path.length > 0);
return paths ? Array.from(new Set(paths)) : [];
};
const extractAttachmentPath = (attachment?: NoteAttachment | null) => {
if (!attachment) {
return null;
}
if (attachment.path) {
return normalizeStoragePath(attachment.path);
}
if (!attachment.src) {
return null;
}
const pathname = getPathname(attachment.src);
if (!pathname) {
return null;
}
const bucketSegment = `/${ATTACHMENTS_BUCKET}/`;
const bucketIndex = pathname.lastIndexOf(bucketSegment);
if (bucketIndex < 0) {
return null;
}
const path = pathname.slice(bucketIndex + bucketSegment.length);
return normalizeStoragePath(path);
};
const getPathname = (value: string) => {
try {
return new URL(value, "http://localhost").pathname;
} catch {
return null;
}
};
const safelyDecodePath = (path: string) => {
try {
return decodeURIComponent(path);
} catch {
return path;
}
};
const normalizeStoragePath = (path: string) => {
const trimmedPath = path.trim();
if (trimmedPath.length === 0) {
return null;
}
const parsedPath = getPathname(trimmedPath);
const candidatePath = parsedPath ?? trimmedPath;
const bucketSegment = `/${ATTACHMENTS_BUCKET}/`;
const bucketIndex = candidatePath.lastIndexOf(bucketSegment);
const withoutBucket =
bucketIndex >= 0
? candidatePath.slice(bucketIndex + bucketSegment.length)
: candidatePath.replace(/^\/+/, "").replace(/^attachments\//, "");
if (withoutBucket.length === 0) {
return null;
}
return safelyDecodePath(withoutBucket);
};
const jsonResponse = (data: unknown, status = 200) =>
new Response(JSON.stringify(data), {
status,
headers: { "Content-Type": "application/json" },
});

View File

@@ -0,0 +1,648 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { McpServer } from "npm:@modelcontextprotocol/sdk@1.28.0/server/mcp.js";
import { WebStandardStreamableHTTPServerTransport } from "npm:@modelcontextprotocol/sdk@1.28.0/server/webStandardStreamableHttp.js";
import { createRemoteJWKSet, jwtVerify, decodeJwt } from "npm:jose@5";
import { Pool } from "https://deno.land/x/postgres@v0.17.0/mod.ts";
import { z } from "npm:zod@^3.25";
import { validateReadOnly, validateWrite } from "./validateSql.ts";
import { TASK_LIST_HTML, TASK_LIST_UI_URI } from "./taskListUi.ts";
import { corsHeaders } from "../_shared/cors.ts";
// --- Environment & Config ---
const SUPABASE_URL = Deno.env.get("SUPABASE_URL")!;
const SUPABASE_JWT_ISSUER =
Deno.env.get("SB_JWT_ISSUER") ?? `${SUPABASE_URL}/auth/v1`;
const CRM_BASE_URL = (Deno.env.get("CRM_BASE_URL") ?? "").replace(/\/$/, "");
const JWKS = createRemoteJWKSet(
new URL(`${SUPABASE_URL}/auth/v1/.well-known/jwks.json`),
);
const connectionString =
Deno.env.get("SUPABASE_DB_URL") ||
"postgresql://postgres:postgres@db:5432/postgres";
const pool = new Pool(connectionString, 1);
// --- URL Helpers ---
function getBaseUrl(req: Request): string {
const forwardedHost = req.headers.get("x-forwarded-host");
if (forwardedHost) {
// When behind a proxy (ngrok, production), always use HTTPS.
// x-forwarded-proto may not survive the Supabase gateway chain.
return `https://${forwardedHost}`;
}
const url = new URL(req.url);
const host = url.host;
// Supabase edge functions see http:// internally, but are served over HTTPS publicly
const proto =
host.includes("localhost") || host.includes("127.0.0.1") ? "http" : "https";
return `${proto}://${host}`;
}
function getResourceMetadataUrl(req: Request): string {
return `${getBaseUrl(req)}/functions/v1/mcp/oauth-protected-resource`;
}
// --- Auth ---
interface AuthInfo {
token: string;
userId: string;
role?: string;
clientId?: string;
}
async function validateToken(req: Request): Promise<AuthInfo | null> {
const authHeader = req.headers.get("authorization");
if (!authHeader) return null;
const [bearer, token] = authHeader.split(" ");
if (bearer !== "Bearer" || !token) return null;
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: SUPABASE_JWT_ISSUER,
});
if (!payload.sub) return null;
return {
token,
userId: payload.sub,
role: payload.role as string | undefined,
clientId: payload.client_id as string | undefined,
};
} catch {
return null;
}
}
// --- Database: get_schema ---
async function getSchemaData(): Promise<string> {
const client = await pool.connect();
try {
// Query 1: All columns from public schema
const columnsResult = await client.queryObject<{
table_name: string;
column_name: string;
data_type: string;
is_nullable: string;
column_default: string | null;
table_type: string;
}>(`
SELECT
c.table_name,
c.column_name,
c.data_type,
c.is_nullable,
c.column_default,
t.table_type
FROM information_schema.columns c
JOIN information_schema.tables t
ON c.table_name = t.table_name AND c.table_schema = t.table_schema
WHERE c.table_schema = 'public'
ORDER BY c.table_name, c.ordinal_position
`);
// Query 2: Foreign key relationships
const fkResult = await client.queryObject<{
source_table: string;
source_column: string;
target_table: string;
target_column: string;
}>(`
SELECT
src.relname AS source_table,
src_att.attname AS source_column,
tgt.relname AS target_table,
tgt_att.attname AS target_column
FROM pg_catalog.pg_constraint con
JOIN pg_catalog.pg_class src ON con.conrelid = src.oid
JOIN pg_catalog.pg_namespace nsp ON src.relnamespace = nsp.oid
JOIN pg_catalog.pg_class tgt ON con.confrelid = tgt.oid
JOIN pg_catalog.pg_attribute src_att
ON src_att.attrelid = con.conrelid AND src_att.attnum = ANY(con.conkey)
JOIN pg_catalog.pg_attribute tgt_att
ON tgt_att.attrelid = con.confrelid AND tgt_att.attnum = ANY(con.confkey)
WHERE con.contype = 'f' AND nsp.nspname = 'public'
ORDER BY src.relname
`);
// Group columns by table
const tables = new Map<
string,
{
type: string;
columns: {
name: string;
type: string;
nullable: boolean;
default: string | null;
}[];
}
>();
for (const row of columnsResult.rows) {
if (!tables.has(row.table_name)) {
tables.set(row.table_name, {
type: row.table_type === "VIEW" ? "View" : "Table",
columns: [],
});
}
tables.get(row.table_name)!.columns.push({
name: row.column_name,
type: row.data_type,
nullable: row.is_nullable === "YES",
default: row.column_default,
});
}
// Group foreign keys by source table
const foreignKeys = new Map<
string,
{ source_column: string; target_table: string; target_column: string }[]
>();
for (const row of fkResult.rows) {
if (!foreignKeys.has(row.source_table)) {
foreignKeys.set(row.source_table, []);
}
foreignKeys.get(row.source_table)!.push({
source_column: row.source_column,
target_table: row.target_table,
target_column: row.target_column,
});
}
// Format output
const lines: string[] = [];
for (const [tableName, table] of tables) {
lines.push(`${table.type}: ${tableName}`);
for (const col of table.columns) {
const parts = [` - ${col.name}: ${col.type}`];
if (col.nullable) parts.push("(nullable)");
if (col.default) parts.push(`default: ${col.default}`);
lines.push(parts.join(" "));
}
const fks = foreignKeys.get(tableName);
if (fks && fks.length > 0) {
lines.push(" Foreign Keys:");
for (const fk of fks) {
lines.push(
` - ${fk.source_column} -> ${fk.target_table}.${fk.target_column}`,
);
}
}
lines.push("");
}
return lines.join("\n");
} finally {
client.release();
}
}
// --- Database: query with RLS ---
async function executeQueryWithRLS(
sql: string,
userToken: string,
validate: (sql: string) => string | null,
): Promise<
{ success: true; data: unknown[] } | { success: false; error: string }
> {
const validationError = validate(sql);
if (validationError) {
return { success: false, error: validationError };
}
const client = await pool.connect();
try {
const jwtClaims = decodeJwt(userToken);
const claimsJson = JSON.stringify(jwtClaims);
await client.queryObject("BEGIN");
// set_config(..., is_local=true) is the parameterized equivalent of
// SET LOCAL — avoids interpolating JWT claims into a SQL string.
await client.queryObject(
"SELECT set_config('role', 'authenticated', true)",
);
await client.queryObject({
text: "SELECT set_config('request.jwt.claims', $1, true)",
args: [claimsJson],
});
const result = await client.queryObject(sql);
await client.queryObject("COMMIT");
// Convert BigInt values to numbers (Deno Postgres returns bigint for
// PostgreSQL int8/count results, but JSON.stringify can't handle them)
const rows = JSON.parse(
JSON.stringify(result.rows, (_key, value) =>
typeof value === "bigint" ? Number(value) : value,
),
);
return { success: true, data: rows };
} catch (error) {
try {
await client.queryObject("ROLLBACK");
} catch {
// Ignore rollback errors
}
const message =
error instanceof AggregateError
? error.errors.map((e) => e.message).join("; ")
: error instanceof Error
? error.message
: String(error);
return { success: false, error: message };
} finally {
client.release();
}
}
// --- MCP Server Factory ---
function createMcpServer(authInfo: AuthInfo): McpServer {
const server = new McpServer({
name: "atomic-crm",
version: "1.0.0",
});
server.registerTool(
"get_schema",
{
title: "Get Database Schema",
description:
"Retrieve the database schema for the user's Atomic CRM instance including all tables, views, columns, types, and foreign key relationships. Views (like contacts_summary, companies_summary) are read-only and provide pre-joined/aggregated data. Use them for search and list queries.",
annotations: { readOnlyHint: true },
},
async () => {
const schema = await getSchemaData();
return { content: [{ type: "text" as const, text: schema }] };
},
);
server.registerTool(
"query",
{
title: "Query CRM Data",
description: `Read data from the user's CRM instance using SQL SELECT queries.
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
Use this tool when the user asks about their CRM data such as:
- Contacts, companies, and deals
- Sales pipeline and forecasting data
- Customer interactions and notes
- Tasks and follow-ups
- Custom fields and metadata
Row Level Security (RLS) is enforced - queries automatically return only data the authenticated user has permission to access.
Use the *_summary views (contacts_summary, companies_summary) for queries that need aggregated data or search capabilities.
To filter by the current user, if the table has a sales_id column, add a WHERE sales_id = auth.uid() clause to your query.
This tool only supports SELECT queries. For INSERT, UPDATE, or DELETE operations, use the mutate tool.
Examples:
- "SELECT id, first_name, last_name, email_fts FROM contacts_summary WHERE email_fts LIKE '%@company.com%'"
- "SELECT name, stage, amount FROM deals WHERE created_at > NOW() - INTERVAL '30 days' ORDER BY amount DESC"
- "SELECT COUNT(*) as total_tasks, type FROM tasks WHERE done_date IS NULL GROUP BY type"
- "SELECT c.first_name, c.last_name, co.name as company_name FROM contacts c JOIN companies co ON c.company_id = co.id WHERE co.sector = 'Technology'"`,
inputSchema: z.object({
sql: z.string().describe("The SQL SELECT query to execute"),
}),
annotations: { readOnlyHint: true },
},
async ({ sql }: { sql: string }) => {
// eslint-disable-next-line no-console
console.log(`[MCP query] user=${authInfo.userId} sql=${sql}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateReadOnly,
);
if (result.success) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result.data, null, 2),
},
],
};
}
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
},
);
server.registerTool(
"mutate",
{
title: "Mutate CRM Data",
description: `Create, update, or delete data in the user's CRM instance using SQL.
IMPORTANT: Before using this tool, you MUST call the get_schema tool first to understand what tables and columns are available in the database.
Use this tool for data modifications such as:
- Creating new contacts, companies, deals, tasks, or notes
- Updating existing records
- Deleting records
Row Level Security (RLS) is enforced - mutations only affect data the authenticated user has permission to modify.
IMPORTANT: Never specify sales_id in INSERT or UPDATE statements — it is automatically set to the authenticated user by a database trigger.
For read-only queries, use the query tool instead.
Examples:
- "INSERT INTO contacts (first_name, last_name, email) VALUES ('John', 'Doe', 'john@example.com')"
- "UPDATE deals SET stage = 'won-deal' WHERE id = 123"
- "DELETE FROM tasks WHERE id = 456"`,
inputSchema: z.object({
sql: z
.string()
.describe("The SQL INSERT, UPDATE, or DELETE statement to execute"),
}),
annotations: { destructiveHint: true },
},
async ({ sql }: { sql: string }) => {
// eslint-disable-next-line no-console
console.log(`[MCP mutate] user=${authInfo.userId} sql=${sql}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateWrite,
);
if (result.success) {
return {
content: [
{
type: "text" as const,
text: JSON.stringify(result.data, null, 2),
},
],
};
}
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
},
);
// --- UI resource for the task-list MCP App ---
// Inject the CRM base URL into the task-list guest HTML
// so contact names can link back to the CRM
const taskListHtml = TASK_LIST_HTML.replace(
/__CRM_BASE_URL__/g,
CRM_BASE_URL,
);
server.registerResource(
"task-list-ui",
TASK_LIST_UI_URI,
{
title: "Task List UI",
description: "Interactive list of tasks with mark-as-done buttons.",
mimeType: "text/html;profile=mcp-app",
},
async (uri: URL) => ({
contents: [
{
uri: uri.href,
mimeType: "text/html;profile=mcp-app",
text: taskListHtml,
},
],
}),
);
const taskSchema = z.object({
id: z
.number()
.int()
.describe("Task id — required for the mark-as-done action"),
text: z.string().nullable().optional().describe("Task description"),
type: z
.string()
.nullable()
.optional()
.describe("Task category/type (rendered as a pill)"),
due_date: z.string().nullable().optional().describe("ISO date string"),
done_date: z
.string()
.nullable()
.optional()
.describe("ISO timestamp if already done; null or omitted for pending"),
contact_name: z
.string()
.nullable()
.optional()
.describe("Full name of the linked contact, if any"),
contact_id: z
.number()
.int()
.nullable()
.optional()
.describe(
"Id of the linked contact — used to render the contact name as a link to the CRM contact page",
),
});
type Task = z.infer<typeof taskSchema>;
server.registerTool(
"display_task_list",
{
title: "Display Task List",
description: `Render an array of task rows as an interactive UI (MCP App) where the user can mark each task as done.
This tool is presentational: it does not query the database. Fetch the rows yourself via the query tool (joining contacts for contact_name when useful), then pass them here. Prefer this over replying with a bulleted list of tasks.
Each task should include at least: id (required, used for the mark-as-done action), text, type, due_date, done_date, and optionally contact_name + contact_id (the UI renders the name as a link to the CRM contact page when contact_id is provided).`,
inputSchema: {
tasks: z.array(taskSchema).describe("Array of task objects to render"),
},
annotations: { readOnlyHint: true },
_meta: {
ui: {
resourceUri: TASK_LIST_UI_URI,
visibility: ["model"],
},
},
},
({ tasks }: { tasks: Task[] }) => {
// eslint-disable-next-line no-console
console.log(
`[MCP display_task_list] user=${authInfo.userId} count=${tasks.length}`,
);
// content carries the display text (used by Claude's guest HTML);
// structuredContent carries the typed data (used by ChatGPT's Apps SDK
// convention). Supplying both keeps the guest host-agnostic.
return {
content: [{ type: "text" as const, text: JSON.stringify(tasks) }],
structuredContent: { tasks },
};
},
);
server.registerTool(
"complete_task",
{
title: "Mark Task Done",
description:
"Mark a single task as done by id. Used by the task-list UI when the user clicks a task's checkmark, and also callable directly by the model.",
inputSchema: {
id: z
.number()
.int()
.positive()
.describe("The id of the task to mark as done"),
},
annotations: { idempotentHint: true },
_meta: {
ui: {
visibility: ["model", "app"],
},
},
},
async ({ id }: { id: number }) => {
// RETURNING id lets us distinguish a successful update from an
// RLS-blocked or non-existent row (executeQueryWithRLS would otherwise
// report success on 0 rows affected).
const sql = `UPDATE tasks SET done_date = NOW() WHERE id = ${id} RETURNING id`;
// eslint-disable-next-line no-console
console.log(`[MCP complete_task] user=${authInfo.userId} id=${id}`);
const result = await executeQueryWithRLS(
sql,
authInfo.token,
validateWrite,
);
if (!result.success) {
return {
content: [{ type: "text" as const, text: `Error: ${result.error}` }],
isError: true,
};
}
if (result.data.length === 0) {
return {
content: [
{
type: "text" as const,
text: `Error: task ${id} not found or permission denied.`,
},
],
isError: true,
};
}
return {
content: [
{ type: "text" as const, text: `Task ${id} marked as done.` },
],
};
},
);
return server;
}
// --- OAuth Protected Resource Metadata ---
function handleProtectedResourceMetadata(req: Request): Response {
const baseUrl = getBaseUrl(req);
return new Response(
JSON.stringify({
resource: `${baseUrl}/functions/v1/mcp`,
authorization_servers: [`${baseUrl}/auth/v1`],
bearer_methods_supported: ["header"],
}),
{
headers: { "Content-Type": "application/json" },
},
);
}
// --- MCP Request Handler ---
async function handleMcpRequest(req: Request): Promise<Response> {
// Validate auth
const authInfo = await validateToken(req);
if (!authInfo) {
const metadataUrl = getResourceMetadataUrl(req);
return new Response("Unauthorized", {
status: 401,
headers: {
"WWW-Authenticate": `Bearer resource_metadata="${metadataUrl}"`,
},
});
}
// Create stateless MCP server + transport for this request
const server = createMcpServer(authInfo);
const transport = new WebStandardStreamableHTTPServerTransport({
sessionIdGenerator: undefined, // Stateless
});
await server.connect(transport);
// Clean up server + transport when the connection closes.
// Do NOT close in a finally block — the SSE response body is a
// ReadableStream that must remain open until the client consumes it.
transport.onclose = () => {
server.close().catch(() => {});
};
try {
return await transport.handleRequest(req);
} catch (error) {
console.error("MCP request error:", error);
await transport.close();
await server.close();
return new Response("Internal Server Error", { status: 500 });
}
}
// --- CORS Helper ---
function withCorsHeaders(response: Response): Response {
const headers = new Headers(response.headers);
for (const [key, value] of Object.entries(corsHeaders)) {
headers.set(key, value);
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
headers,
});
}
// --- Route Dispatcher ---
Deno.serve(async (req: Request) => {
// Handle CORS preflight
if (req.method === "OPTIONS") {
return new Response(null, { status: 204, headers: corsHeaders });
}
const url = new URL(req.url);
const path = url.pathname;
// GET /functions/v1/mcp/oauth-protected-resource → RFC 9728 metadata
if (path.endsWith("/oauth-protected-resource") && req.method === "GET") {
return withCorsHeaders(handleProtectedResourceMetadata(req));
}
// POST/GET/DELETE /functions/v1/mcp → MCP protocol handler
if (path.endsWith("/mcp") || path.endsWith("/mcp/")) {
return withCorsHeaders(await handleMcpRequest(req));
}
return withCorsHeaders(new Response("Not Found", { status: 404 }));
});

View File

@@ -0,0 +1,349 @@
// Task-list MCP App. Everything lives in this single file: template,
// styles, and glue. Plain vanilla JS — no external scripts, because host
// CSPs (e.g. Claude.ai) only allow 'self' + 'unsafe-inline' for scripts.
// Syntax highlighted if the IDE has the es6-string-html extension enabled.
export const TASK_LIST_UI_URI = "ui://atomic-crm/task-list";
export const TASK_LIST_HTML = /*html*/ `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; base-uri 'none'; form-action 'none'; frame-src 'none'">
<title>Tasks</title>
<style>
:root { color-scheme: light dark; }
* { box-sizing: border-box; }
html, body { margin:0; padding:0; font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; font-size:14px; background:transparent; color:#111; }
body { padding:8px 4px; overflow:hidden; }
.task { display:flex; align-items:flex-start; gap:10px; padding:10px 12px; border:1px solid rgba(128,128,128,0.25); border-radius:8px; margin-bottom:8px; background:rgba(0,0,0,0.02); transition:opacity 0.2s ease; }
.task.done { opacity:0.45; }
.task.updating .check { opacity:0.5; cursor:wait; }
.check { width:20px; height:20px; min-width:20px; margin-top:1px; border:1.5px solid #888; border-radius:999px; background:transparent; cursor:pointer; padding:0; display:inline-flex; align-items:center; justify-content:center; color:transparent; font-size:12px; line-height:1; transition:background 0.15s, border-color 0.15s, color 0.15s; }
.check:hover:not(:disabled) { border-color:#2563eb; background:rgba(37,99,235,0.12); }
.check.checked { background:#10b981; border-color:#10b981; color:white; }
.check.checked::after { content:"\\2713"; }
.task-content { flex:1; min-width:0; }
.task-text { font-weight:500; white-space:pre-wrap; word-break:break-word; }
.task-meta { margin-top:3px; font-size:12px; color:#6b7280; display:flex; flex-wrap:wrap; gap:10px; }
.pill { display:inline-block; padding:1px 6px; border-radius:4px; background:rgba(128,128,128,0.22); color:#374151; }
.contact { color:#2563eb; font-weight:500; }
a.contact { text-decoration:none; }
a.contact:hover { text-decoration:underline; }
.empty { text-align:center; color:#6b7280; padding:16px 8px; }
.error { padding:8px 10px; border-radius:6px; border:1px solid rgba(220,38,38,0.4); background:rgba(220,38,38,0.08); color:#dc2626; font-size:12px; margin-bottom:8px; }
html.dark body { color:#f5f5f5; }
html.dark .task { background:rgba(255,255,255,0.04); }
html.dark .task-meta { color:#b8b8b8; }
html.dark .pill { color:#e5e5e5; }
html.dark .contact { color:#93c5fd; }
html.dark .empty { color:#b8b8b8; }
html.dark .error { color:#f87171; }
html.dark .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
@media (prefers-color-scheme: dark) {
html:not(.light) body { color:#f5f5f5; }
html:not(.light) .task { background:rgba(255,255,255,0.04); }
html:not(.light) .task-meta { color:#b8b8b8; }
html:not(.light) .pill { color:#e5e5e5; }
html:not(.light) .contact { color:#93c5fd; }
html:not(.light) .empty { color:#b8b8b8; }
html:not(.light) .error { color:#f87171; }
html:not(.light) .check:hover:not(:disabled) { border-color:#60a5fa; background:rgba(96,165,250,0.18); }
}
</style>
</head>
<body>
<div id="app"></div>
<script>
(() => {
// Injected server-side; empty string when not configured, in which case
// contact names render as plain text instead of links.
const CRM_BASE_URL = '__CRM_BASE_URL__';
// -------- JSON-RPC plumbing over postMessage --------
const pending = new Map();
const RPC_TIMEOUT_MS = 10000;
let nextId = 1;
const post = (msg) => window.parent.postMessage(msg, '*');
const rpc = (method, params) => new Promise((resolve, reject) => {
const id = nextId++;
const timer = setTimeout(() => {
if (!pending.has(id)) return;
pending.delete(id);
reject(new Error("RPC timeout after " + RPC_TIMEOUT_MS + "ms: " + method));
}, RPC_TIMEOUT_MS);
pending.set(id, { resolve, reject, timer });
post({ jsonrpc: '2.0', id, method, params });
});
// -------- State --------
let tasks = [];
let errorMsg = '';
let ready = false;
const updating = Object.create(null);
// -------- Rendering (vanilla DOM) --------
const root = document.getElementById('app');
const contactUrl = (id) =>
CRM_BASE_URL ? CRM_BASE_URL + '/#/contacts/' + id + '/show' : '';
const formatDate = (value) => {
const d = new Date(value);
if (isNaN(d.getTime())) return String(value);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
};
const el = (tag, attrs, children) => {
const node = document.createElement(tag);
if (attrs) {
for (const k in attrs) {
const v = attrs[k];
if (v == null || v === false) continue;
if (k === 'class') node.className = v;
else if (k === 'text') node.textContent = v;
else if (k.startsWith('on') && typeof v === 'function') node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v === true ? '' : String(v));
}
}
if (children) {
for (const c of children) {
if (c == null || c === false) continue;
node.appendChild(typeof c === 'string' ? document.createTextNode(c) : c);
}
}
return node;
};
const render = () => {
root.replaceChildren();
if (errorMsg) {
root.appendChild(el('div', { class: 'error', text: errorMsg }));
}
if (!ready) {
root.appendChild(el('div', { class: 'empty', text: 'Waiting for data\u2026' }));
return;
}
if (!tasks.length) {
root.appendChild(el('div', { class: 'empty', text: 'No pending tasks.' }));
return;
}
for (const t of tasks) {
const done = !!t.done_date;
const upd = !!updating[t.id];
const classes = ['task'];
if (done) classes.push('done');
if (upd) classes.push('updating');
const meta = [];
if (t.contact_name && t.contact_id && contactUrl(t.contact_id)) {
meta.push(el('a', {
class: 'contact',
href: contactUrl(t.contact_id),
target: '_blank',
rel: 'noopener noreferrer',
text: t.contact_name,
}));
} else if (t.contact_name) {
meta.push(el('span', { class: 'contact', text: t.contact_name }));
}
if (t.type) meta.push(el('span', { class: 'pill', text: t.type }));
if (t.due_date) meta.push(el('span', { text: 'Due ' + formatDate(t.due_date) }));
const checkClasses = ['check'];
if (done) checkClasses.push('checked');
const card = el('div', { class: classes.join(' ') }, [
el('button', {
type: 'button',
class: checkClasses.join(' '),
'aria-label': done ? 'Already done' : 'Mark as done',
disabled: done,
onclick: () => complete(t),
}),
el('div', { class: 'task-content' }, [
el('div', { class: 'task-text', text: t.text || '(no description)' }),
meta.length ? el('div', { class: 'task-meta' }, meta) : null,
]),
]);
root.appendChild(card);
}
};
// -------- Actions --------
const complete = (t) => {
if (t.done_date || updating[t.id]) return;
updating[t.id] = true;
render();
rpc('tools/call', { name: 'complete_task', arguments: { id: t.id } })
.then((result) => {
updating[t.id] = false;
if (result && result.isError) {
errorMsg = (result.content && result.content[0] && result.content[0].text) || 'Failed to mark task as done';
} else {
errorMsg = '';
t.done_date = new Date().toISOString();
}
render();
})
.catch((e) => {
updating[t.id] = false;
errorMsg = 'Could not mark as done: ' + ((e && e.message) || String(e));
render();
});
};
// Extract an array of tasks from a tool result, handling the multiple
// shapes hosts use. Claude sends the JSON-stringified array in
// content[0].text. ChatGPT (OpenAI Apps SDK convention) instead populates
// structuredContent with typed data — either the array directly or a
// wrapper object like { tasks: [...] }.
const extractTasks = (result) => {
if (!result) return null;
const sc = result.structuredContent;
if (Array.isArray(sc)) return sc;
if (sc && Array.isArray(sc.tasks)) return sc.tasks;
const text = result.content && result.content[0] && result.content[0].text;
if (typeof text === 'string' && text.length > 0) {
try {
const parsed = JSON.parse(text);
if (Array.isArray(parsed)) return parsed;
if (parsed && Array.isArray(parsed.tasks)) return parsed.tasks;
} catch (_) {
// fall through
}
}
return null;
};
const applyToolResult = (result) => {
const extracted = extractTasks(result);
if (extracted) {
tasks = extracted;
errorMsg = '';
} else {
tasks = [];
errorMsg = 'Could not extract task list from tool result';
}
ready = true;
render();
};
const applyToolInput = (input) => {
const args = input && input.arguments;
if (args && Array.isArray(args.tasks)) {
tasks = args.tasks;
errorMsg = '';
ready = true;
render();
}
};
// -------- Bootstrap --------
window.addEventListener('message', (event) => {
// Origin-agnostic (the host's origin is unknown at build time);
// just assert the message came from the parent frame.
if (event.source !== window.parent) return;
const m = event.data;
if (!m || m.jsonrpc !== '2.0') return;
if (m.id !== undefined && m.method === undefined) {
const w = pending.get(m.id);
if (!w) return;
pending.delete(m.id);
clearTimeout(w.timer);
if (m.error) w.reject(new Error(m.error.message || 'RPC error'));
else w.resolve(m.result);
return;
}
if (m.method === 'ui/notifications/tool-result') {
applyToolResult(m.params);
} else if (
m.method === 'ui/notifications/tool-input-partial' ||
m.method === 'ui/notifications/tool-input'
) {
// Render progressively from streamed tool arguments so the user sees
// tasks appear as the model generates them instead of a blank wait.
// Claude uses tool-input-partial; ChatGPT streams via tool-input.
applyToolInput(m.params);
} else if (
m.method === 'ui/notifications/host-context-changed' ||
m.method === 'ui/notifications/theme'
) {
const theme = m.params && m.params.theme;
if (theme) {
const cl = document.documentElement.classList;
cl.toggle('dark', theme === 'dark');
cl.toggle('light', theme === 'light');
}
}
});
// Iframe has no intrinsic height from the host's perspective; measure our
// own content and ask the host to resize via ui/notifications/size-changed
// (per MCP Apps spec; hosts like Claude Desktop ignore non-spec ui/size).
let lastReportedHeight = 0;
const reportSize = () => {
const h = Math.max(
document.body.scrollHeight,
document.body.offsetHeight,
document.documentElement.scrollHeight,
document.documentElement.offsetHeight,
);
const height = h + 2;
if (height === lastReportedHeight) return;
lastReportedHeight = height;
post({
jsonrpc: '2.0',
method: 'ui/notifications/size-changed',
params: { width: document.documentElement.clientWidth, height },
});
};
render();
// -------- Spec-compliant MCP Apps handshake --------
// Per the MCP Apps spec, the host MUST NOT send any notification (including
// tool-result) before it receives ui/notifications/initialized from the
// view. We therefore: (1) send ui/initialize, (2) await the response,
// (3) send ui/notifications/initialized, and only then wire up the
// ResizeObserver so the host is not spammed with size reports before handshake.
rpc('ui/initialize', {
protocolVersion: '2025-06-18',
appInfo: { name: 'atomic-crm-task-list', version: '1.0.0' },
appCapabilities: {
tools: { listChanged: false },
availableDisplayModes: ['inline'],
},
})
.then((result) => {
// Apply theme from host context if provided
const theme = result && result.hostContext && result.hostContext.theme;
if (theme) {
const cl = document.documentElement.classList;
cl.toggle('dark', theme === 'dark');
cl.toggle('light', theme === 'light');
}
post({ jsonrpc: '2.0', method: 'ui/notifications/initialized', params: {} });
if (typeof ResizeObserver !== 'undefined') {
new ResizeObserver(reportSize).observe(document.body);
}
})
.catch((e) => {
errorMsg = 'Failed to initialize MCP App: ' + ((e && e.message) || e);
render();
});
})();
</script>
</body>
</html>
`;

View File

@@ -0,0 +1,122 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { validateReadOnly, validateWrite } from "./validateSql";
describe("validateReadOnly", () => {
it.each([
["simple SELECT", "SELECT * FROM contacts"],
[
"SELECT with WHERE",
"SELECT id, name FROM contacts WHERE created_at > NOW() - INTERVAL '30 days'",
],
[
"SELECT with JOIN",
"SELECT c.name, co.name FROM contacts c JOIN companies co ON c.company_id = co.id",
],
[
"SELECT with subquery",
"SELECT * FROM contacts WHERE company_id IN (SELECT id FROM companies WHERE sector = 'Tech')",
],
[
"read-only CTE",
"WITH recent AS (SELECT * FROM contacts WHERE created_at > NOW() - INTERVAL '7 days') SELECT * FROM recent",
],
[
"aggregate query",
"SELECT COUNT(*) as total, type FROM tasks GROUP BY type",
],
[
"DELETE in string literal",
"SELECT * FROM contacts WHERE status = 'DELETE'",
],
[
"DROP in string literal",
"SELECT * FROM logs WHERE message = 'DROP TABLE failed'",
],
[
"UPDATE in string literal",
"SELECT * FROM contacts WHERE note = 'Please UPDATE your info'",
],
[
"DROP in block comment",
"SELECT /* DROP TABLE contacts */ * FROM contacts",
],
["DROP in line comment", "SELECT * FROM contacts -- DROP TABLE contacts"],
])("allows %s", (_label, sql) => {
expect(validateReadOnly(sql)).toBeNull();
});
it.each([
["INSERT", "INSERT INTO contacts (name) VALUES ('test')"],
["UPDATE", "UPDATE contacts SET name = 'test'"],
["DELETE", "DELETE FROM contacts WHERE id = 1"],
[
"writable CTE (DELETE)",
"WITH d AS (DELETE FROM contacts RETURNING *) SELECT * FROM d",
],
[
"writable CTE (UPDATE)",
"WITH u AS (UPDATE contacts SET name = 'x' RETURNING *) SELECT * FROM u",
],
[
"writable CTE (INSERT)",
"WITH i AS (INSERT INTO contacts (name) VALUES ('x') RETURNING *) SELECT * FROM i",
],
["DROP TABLE", "DROP TABLE contacts"],
["CREATE TABLE", "CREATE TABLE evil (id int)"],
["ALTER TABLE", "ALTER TABLE contacts ADD COLUMN x int"],
["TRUNCATE", "TRUNCATE contacts"],
["SET", "SET LOCAL role = 'postgres'"],
["DO block", "DO $$ BEGIN END $$"],
["multi-statement (SELECT; DROP)", "SELECT 1; DROP TABLE contacts"],
["multi-statement (SELECT; SET)", "SELECT 1; SET LOCAL role = 'postgres'"],
["multi-statement (two SELECTs)", "SELECT 1; SELECT 2"],
["unparseable SQL", "NOT VALID SQL %%%"],
])("rejects %s", (_label, sql) => {
expect(validateReadOnly(sql)).not.toBeNull();
});
});
describe("validateWrite", () => {
it.each([
[
"INSERT",
"INSERT INTO contacts (first_name, last_name) VALUES ('John', 'Doe')",
],
["UPDATE", "UPDATE contacts SET name = 'test' WHERE id = 1"],
["DELETE", "DELETE FROM contacts WHERE id = 1"],
[
"INSERT with RETURNING",
"INSERT INTO contacts (name) VALUES ('test') RETURNING id",
],
[
"UPDATE with subquery",
"UPDATE contacts SET company_id = (SELECT id FROM companies WHERE name = 'Acme') WHERE id = 1",
],
[
"writable CTE with INSERT",
"WITH d AS (DELETE FROM old_contacts RETURNING *) INSERT INTO archive SELECT * FROM d",
],
])("allows %s", (_label, sql) => {
expect(validateWrite(sql)).toBeNull();
});
it.each([
["standalone SELECT", "SELECT * FROM contacts"],
["DROP TABLE", "DROP TABLE contacts"],
["CREATE TABLE", "CREATE TABLE evil (id int)"],
["TRUNCATE", "TRUNCATE contacts"],
["SET", "SET LOCAL role = 'postgres'"],
[
"multi-statement (DELETE; DROP)",
"DELETE FROM contacts; DROP TABLE contacts",
],
[
"multi-statement (DELETE; SET)",
"DELETE FROM contacts; SET LOCAL role = 'postgres'",
],
["unparseable SQL", "NOT VALID SQL %%%"],
])("rejects %s", (_label, sql) => {
expect(validateWrite(sql)).not.toBeNull();
});
});

View File

@@ -0,0 +1,73 @@
import { parse, type Statement } from "npm:pgsql-ast-parser@^12";
const ALLOWED_READ_TYPES = new Set(["select", "with"]);
const ALLOWED_WRITE_TYPES = new Set(["insert", "update", "delete", "with"]);
// Collect all statement types found in a parsed AST, including inner
// statements in WITH (CTE) bindings which can contain writable DML.
function collectStatementTypes(stmts: Statement[]): Set<string> {
const types = new Set<string>();
for (const stmt of stmts) {
types.add(stmt.type);
if (stmt.type === "with" && "bind" in stmt && Array.isArray(stmt.bind)) {
for (const cte of stmt.bind) {
if (cte.statement?.type) {
types.add(cte.statement.type);
}
}
}
}
return types;
}
export function validateReadOnly(sql: string): string | null {
let stmts: Statement[];
try {
stmts = parse(sql);
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
// pgsql-ast-parser appends a parse-table dump that is useless to the
// LLM and consumes many tokens; keep the diagnostic prefix only.
const message = raw.split("Here is the state of my parse table")[0].trim();
return `Failed to parse SQL: ${message}`;
}
if (stmts.length === 0) {
return "Empty query.";
}
if (stmts.length > 1) {
return "Only a single statement is allowed.";
}
const types = collectStatementTypes(stmts);
for (const type of types) {
if (!ALLOWED_READ_TYPES.has(type)) {
return `Statement type "${type}" is not allowed in read-only queries. Use the mutate tool for data modifications.`;
}
}
return null;
}
export function validateWrite(sql: string): string | null {
let stmts: Statement[];
try {
stmts = parse(sql);
} catch (err) {
const raw = err instanceof Error ? err.message : String(err);
// pgsql-ast-parser appends a parse-table dump that is useless to the
// LLM and consumes many tokens; keep the diagnostic prefix only.
const message = raw.split("Here is the state of my parse table")[0].trim();
return `Failed to parse SQL: ${message}`;
}
if (stmts.length === 0) {
return "Empty query.";
}
if (stmts.length > 1) {
return "Only a single statement is allowed.";
}
const types = collectStatementTypes(stmts);
for (const type of types) {
if (!ALLOWED_WRITE_TYPES.has(type)) {
return `Statement type "${type}" is not allowed. Only INSERT, UPDATE, and DELETE statements are supported.`;
}
}
return null;
}

View File

@@ -0,0 +1,194 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { sql, type Selectable } from "https://esm.sh/kysely@0.27.2";
import { db, type ContactsTable, CompiledQuery } from "../_shared/db.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
type Contact = Selectable<ContactsTable>;
// Helper functions to merge arrays
function mergeArraysUnique<T>(arr1: T[], arr2: T[]): T[] {
return [...new Set([...arr1, ...arr2])];
}
function mergeObjectArraysUnique<T>(
arr1: T[],
arr2: T[],
getKey: (item: T) => string,
): T[] {
const map = new Map<string, T>();
arr1.forEach((item) => {
const key = getKey(item);
if (key) map.set(key, item);
});
arr2.forEach((item) => {
const key = getKey(item);
if (key && !map.has(key)) {
map.set(key, item);
}
});
return Array.from(map.values());
}
function mergeContactData(winner: Contact, loser: Contact) {
// Merge emails
const mergedEmails = mergeObjectArraysUnique(
winner.email_jsonb || [],
loser.email_jsonb || [],
(email: any) => email.email,
);
// Merge phones
const mergedPhones = mergeObjectArraysUnique(
winner.phone_jsonb || [],
loser.phone_jsonb || [],
(phone: any) => phone.number,
);
const selectedAvatar =
winner.avatar && winner.avatar.src ? winner.avatar : loser.avatar;
return {
avatar: selectedAvatar ? (JSON.stringify(selectedAvatar) as any) : null,
gender: winner.gender ?? loser.gender,
first_name: winner.first_name ?? loser.first_name,
last_name: winner.last_name ?? loser.last_name,
title: winner.title ?? loser.title,
company_id: winner.company_id ?? loser.company_id,
email_jsonb: JSON.stringify(mergedEmails) as any,
phone_jsonb: JSON.stringify(mergedPhones) as any,
linkedin_url: winner.linkedin_url || loser.linkedin_url,
background: winner.background ?? loser.background,
has_newsletter: winner.has_newsletter ?? loser.has_newsletter,
first_seen: winner.first_seen ?? loser.first_seen,
last_seen:
winner.last_seen && loser.last_seen
? winner.last_seen > loser.last_seen
? winner.last_seen
: loser.last_seen
: (winner.last_seen ?? loser.last_seen),
sales_id: winner.sales_id ?? loser.sales_id,
tags: mergeArraysUnique(winner.tags || [], loser.tags || []),
};
}
async function mergeContacts(
loserId: number,
winnerId: number,
userId: string,
) {
try {
return await db.transaction().execute(async (trx) => {
// Enable RLS by switching to authenticated role and setting user context
await trx.executeQuery(CompiledQuery.raw("SET LOCAL ROLE authenticated"));
await trx.executeQuery(
CompiledQuery.raw(
`SELECT set_config('request.jwt.claim.sub', '${userId}', true)`,
),
);
// 1. Fetch both contacts
const [winner, loser] = await Promise.all([
trx
.selectFrom("contacts")
.selectAll()
.where("id", "=", winnerId)
.executeTakeFirstOrThrow(),
trx
.selectFrom("contacts")
.selectAll()
.where("id", "=", loserId)
.executeTakeFirstOrThrow(),
]);
// 2. Reassign tasks from loser to winner
await trx
.updateTable("tasks")
.set({ contact_id: winnerId })
.where("contact_id", "=", loserId)
.execute();
// 3. Reassign notes from loser to winner
await trx
.updateTable("contact_notes")
.set({ contact_id: winnerId })
.where("contact_id", "=", loserId)
.execute();
// 4. Update deals - replace loserId with winnerId in contact_ids array
const deals = await trx
.selectFrom("deals")
.selectAll()
.where(sql`contact_ids @> ARRAY[${loserId}]::bigint[]`)
.execute();
for (const deal of deals) {
const newContactIds = [
...new Set(
deal.contact_ids.filter((id) => id !== loserId).concat(winnerId),
),
];
await trx
.updateTable("deals")
.set({ contact_ids: newContactIds })
.where("id", "=", deal.id)
.execute();
}
// 5. Merge and update winner contact
const mergedData = mergeContactData(winner as Contact, loser as Contact);
await trx
.updateTable("contacts")
.set(mergedData)
.where("id", "=", winnerId)
.execute();
// 6. Delete loser contact
await trx.deleteFrom("contacts").where("id", "=", loserId).execute();
return { success: true, winnerId };
});
} catch (error) {
console.error("Transaction failed:", error);
throw error;
}
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
// Handle POST request
if (req.method === "POST") {
try {
const { loserId, winnerId } = await req.json();
if (!loserId || !winnerId) {
return createErrorResponse(400, "Missing loserId or winnerId");
}
const result = await mergeContacts(loserId, winnerId, user.id);
return new Response(JSON.stringify(result), {
headers: { "Content-Type": "application/json", ...corsHeaders },
});
} catch (error) {
console.error("Merge failed:", error);
return createErrorResponse(
500,
`Failed to merge contacts: ${
error instanceof Error ? error.message : "Unknown error"
}`,
);
}
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

View File

@@ -0,0 +1,647 @@
// @vitest-environment node
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
addNoteToContact,
findActiveSaleByEmail,
getOrCreateCompanyFromDomain,
getOrCreateContactFromEmailInfo,
} from "./addNoteToContact";
const mockFrom = vi.hoisted(() => vi.fn());
vi.mock("../_shared/supabaseAdmin.ts", () => ({
supabaseAdmin: {
from: (...args: unknown[]) => mockFrom(...args),
},
}));
const secondaryLookupResult = (
rows: unknown[],
error: { message: string } | null = null,
) => ({
neq: () => ({
order: () => ({
limit: () => Promise.resolve({ data: error ? null : rows, error }),
}),
}),
});
const primaryLookupMiss = {
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
};
describe("addNoteToContact", () => {
beforeEach(() => {
vi.clearAllMocks();
});
afterAll(() => {
vi.resetAllMocks();
});
describe("getOrCreateCompanyFromDomain", () => {
it("returns the existing company when it already exists in the database", async () => {
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom.mockReturnValue({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
});
const result = await getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
});
expect(result).toEqual(existingCompany);
expect(mockFrom).toHaveBeenCalledWith("companies");
});
it("throws when fetching the company fails", async () => {
mockFrom.mockReturnValue({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
await expect(
getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
}),
).rejects.toThrow(
"Could not fetch companies from database, name: acme.com, error: DB error",
);
});
it("returns null for a known mail provider domain without creating a company", async () => {
const result = await getOrCreateCompanyFromDomain({
domain: "gmail.com",
salesId: 42,
companyName: "Gmail",
website: "https://gmail.com",
});
expect(result).toBeNull();
// No database calls should be made for mail providers
expect(mockFrom).not.toHaveBeenCalled();
});
it("creates and returns a new company when it does not exist and domain is not a mail provider", async () => {
const newCompany = {
id: 2,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
// first call: fetch
select: () => ({
or: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// second call: insert
insert: () => ({
select: () => Promise.resolve({ data: [newCompany], error: null }),
}),
});
const result = await getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
});
expect(result).toEqual(newCompany);
expect(mockFrom).toHaveBeenCalledTimes(2);
expect(mockFrom).toHaveBeenNthCalledWith(2, "companies");
});
it("throws when creating the company fails", async () => {
mockFrom
.mockReturnValueOnce({
select: () => ({
or: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
insert: () => ({
select: () =>
Promise.resolve({
data: null,
error: { message: "Insert failed" },
}),
}),
});
await expect(
getOrCreateCompanyFromDomain({
domain: "acme.com",
salesId: 42,
companyName: "Acme",
website: "https://acme.com",
}),
).rejects.toThrow(
"Could not create company in database, domain: acme.com, error: Insert failed",
);
});
});
describe("getOrCreateContactFromEmailInfo", () => {
const contactParams = {
email: "alice@acme.com",
firstName: "Alice",
lastName: "Smith",
salesId: 42,
domain: "acme.com",
companyName: "Acme",
website: "https://acme.com",
};
it("returns the existing contact when it already exists in the database", async () => {
const existingContact = {
id: 10,
first_name: "Alice",
last_name: "Smith",
};
mockFrom.mockReturnValue({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingContact, error: null }),
}),
}),
});
const result = await getOrCreateContactFromEmailInfo(contactParams);
expect(result).toEqual(existingContact);
expect(mockFrom).toHaveBeenCalledWith("contacts");
});
it("throws when fetching the contact fails", async () => {
mockFrom.mockReturnValue({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
await expect(
getOrCreateContactFromEmailInfo(contactParams),
).rejects.toThrow(
"Could not fetch contact from database, email: alice@acme.com, error: DB error",
);
});
it("creates and returns a new contact with the associated company", async () => {
const newContact = {
id: 11,
first_name: "Alice",
last_name: "Smith",
company_id: 1,
};
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
// 1st call: fetch contact → not found
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 2nd call: fetch company → found
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 3rd call: insert contact
insert: () => ({
select: () => Promise.resolve({ data: [newContact], error: null }),
}),
});
const result = await getOrCreateContactFromEmailInfo(contactParams);
expect(result).toEqual(newContact);
expect(mockFrom).toHaveBeenCalledTimes(3);
});
it("creates a contact with null company_id when domain is a mail provider", async () => {
const newContact = {
id: 12,
first_name: "Alice",
last_name: "Smith",
company_id: null,
};
mockFrom
.mockReturnValueOnce({
// 1st call: fetch contact → not found
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
// 2nd call: insert contact (with null company_id)
insert: () => ({
select: () => Promise.resolve({ data: [newContact], error: null }),
}),
});
const result = await getOrCreateContactFromEmailInfo({
...contactParams,
email: "alice@gmail.com",
domain: "gmail.com",
companyName: "Gmail",
website: "https://gmail.com",
});
expect(result).toEqual(newContact);
// Only 2 froms: contacts fetch, contacts insert (no company fetch/insert for mail provider)
expect(mockFrom).toHaveBeenCalledTimes(2);
});
it("throws when creating the contact fails", async () => {
const existingCompany = {
id: 1,
name: "Acme",
website: "https://acme.com",
sales_id: 42,
};
mockFrom
.mockReturnValueOnce({
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
})
.mockReturnValueOnce({
select: () => ({
or: () => ({
maybeSingle: () =>
Promise.resolve({ data: existingCompany, error: null }),
}),
}),
})
.mockReturnValueOnce({
insert: () => ({
select: () =>
Promise.resolve({
data: null,
error: { message: "Insert failed" },
}),
}),
});
await expect(
getOrCreateContactFromEmailInfo(contactParams),
).rejects.toThrow(
"Could not create contact in database, email: alice@acme.com, error: Insert failed",
);
});
});
describe("findActiveSaleByEmail", () => {
it("returns the active sale whose primary email matches the sender", async () => {
const salesRecord = { id: 1, email: "sales@company.com" };
mockFrom.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () =>
Promise.resolve({ data: salesRecord, error: null }),
}),
}),
}),
});
const result = await findActiveSaleByEmail("sales@company.com");
expect(result.data).toEqual(salesRecord);
expect(mockFrom).toHaveBeenCalledTimes(1);
});
it("looks up the secondary emails when no primary email matches", async () => {
const salesRecord = {
id: 1,
email: "sales@company.com",
secondary_emails: ["perso@gmail.com"],
};
const contains = vi
.fn()
.mockReturnValue(secondaryLookupResult([salesRecord]));
mockFrom
.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
})
.mockReturnValueOnce({ select: () => ({ contains }) });
const result = await findActiveSaleByEmail("perso@gmail.com");
expect(result.data).toEqual(salesRecord);
expect(contains).toHaveBeenCalledWith(
"secondary_emails",
'["perso@gmail.com"]',
);
});
it("attributes to nobody when two sales share the same secondary email", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () =>
secondaryLookupResult([
{ id: 3, email: "a@company.com" },
{ id: 9, email: "b@company.com" },
]),
}),
});
const result = await findActiveSaleByEmail("shared@x.com");
expect(result.data).toBe(null);
expect(result.error).toBe(null);
consoleSpy.mockRestore();
});
it("names both sales in the logs when it refuses an ambiguous sender", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () =>
secondaryLookupResult([
{ id: 3, email: "a@company.com" },
{ id: 9, email: "b@company.com" },
]),
}),
});
await findActiveSaleByEmail("shared@x.com");
expect(consoleSpy).toHaveBeenCalledWith(
expect.stringContaining("shared@x.com"),
);
expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("3, 9"));
consoleSpy.mockRestore();
});
it("surfaces a failure of the secondary lookup instead of reporting no sale", async () => {
mockFrom.mockReturnValueOnce(primaryLookupMiss).mockReturnValueOnce({
select: () => ({
contains: () => secondaryLookupResult([], { message: "DB error" }),
}),
});
const result = await findActiveSaleByEmail("perso@gmail.com");
expect(result.data).toBe(null);
expect(result.error).toEqual({ message: "DB error" });
});
it("returns no sale rather than an error when nothing matches", async () => {
mockFrom
.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
}),
}),
})
.mockReturnValueOnce({
select: () => ({ contains: () => secondaryLookupResult([]) }),
});
const result = await findActiveSaleByEmail("nobody@x.com");
expect(result.data).toBe(null);
expect(result.error).toBe(null);
});
it("lowercases the sender, since the jsonb lookup is case sensitive unlike the citext column", async () => {
const salesRecord = {
id: 1,
email: "sales@company.com",
secondary_emails: ["perso@gmail.com"],
};
const eq = vi.fn().mockReturnValue({
neq: () => ({
maybeSingle: () => Promise.resolve({ data: null, error: null }),
}),
});
const contains = vi
.fn()
.mockReturnValue(secondaryLookupResult([salesRecord]));
mockFrom
.mockReturnValueOnce({ select: () => ({ eq }) })
.mockReturnValueOnce({ select: () => ({ contains }) });
const result = await findActiveSaleByEmail(" Perso@Gmail.COM ");
expect(eq).toHaveBeenCalledWith("email", "perso@gmail.com");
expect(contains).toHaveBeenCalledWith(
"secondary_emails",
'["perso@gmail.com"]',
);
expect(result.data).toEqual(salesRecord);
});
it("does not look up the secondary emails when the first query fails", async () => {
mockFrom.mockReturnValueOnce({
select: () => ({
eq: () => ({
neq: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
}),
});
const result = await findActiveSaleByEmail("sales@company.com");
expect(result.error).toEqual({ message: "DB error" });
expect(mockFrom).toHaveBeenCalledTimes(1);
});
});
describe("addNoteToContact", () => {
const sales = { id: 1 };
const baseParams = {
sales,
salesEmail: "sales@company.com",
email: "alice@acme.com",
domain: "acme.com",
firstName: "Alice",
lastName: "Smith",
noteContent: "A note",
attachments: [],
companyName: "Acme",
website: "https://acme.com",
};
const contactFound = (contact: unknown) => ({
select: () => ({
contains: () => ({
maybeSingle: () => Promise.resolve({ data: contact, error: null }),
}),
}),
});
it("creates a note and returns undefined on success", async () => {
const existingContact = {
id: 10,
first_name: "Alice",
last_name: "Smith",
};
mockFrom
.mockReturnValueOnce(contactFound(existingContact))
.mockReturnValueOnce({
insert: () => Promise.resolve({ error: null }),
})
.mockReturnValueOnce({
update: () => ({
eq: () => Promise.resolve({ error: null }),
}),
});
const result = await addNoteToContact(baseParams);
expect(result).toBeUndefined();
expect(mockFrom).toHaveBeenCalledTimes(3);
expect(mockFrom).toHaveBeenNthCalledWith(2, "contact_notes");
expect(mockFrom).toHaveBeenNthCalledWith(3, "contacts");
});
it("files the note under the sale it was given, without looking one up", async () => {
const insertNote = vi.fn().mockResolvedValue({ error: null });
mockFrom
.mockReturnValueOnce(contactFound({ id: 10 }))
.mockReturnValueOnce({ insert: insertNote })
.mockReturnValueOnce({
update: () => ({
eq: () => Promise.resolve({ error: null }),
}),
});
const result = await addNoteToContact({
...baseParams,
sales: { id: 7 },
});
expect(result).toBeUndefined();
expect(insertNote).toHaveBeenCalledWith(
expect.objectContaining({ sales_id: 7, contact_id: 10 }),
);
expect(mockFrom).not.toHaveBeenCalledWith("sales");
});
it("returns 500 when inserting the note into contact_notes fails", async () => {
mockFrom
.mockReturnValueOnce(contactFound({ id: 10 }))
.mockReturnValueOnce({
insert: () =>
Promise.resolve({ error: { message: "Insert failed" } }),
});
const response = await addNoteToContact(baseParams);
expect(response).toBeInstanceOf(Response);
expect(response!.status).toBe(500);
expect(await response!.text()).toBe(
"Could not add note to contact alice@acme.com, sales sales@company.com",
);
expect(mockFrom).toHaveBeenCalledTimes(2);
});
it("returns 500 when getOrCreateContactFromEmailInfo throws", async () => {
const consoleSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {});
mockFrom.mockReturnValueOnce({
select: () => ({
contains: () => ({
maybeSingle: () =>
Promise.resolve({ data: null, error: { message: "DB error" } }),
}),
}),
});
const response = await addNoteToContact(baseParams);
expect(response).toBeInstanceOf(Response);
expect(response!.status).toBe(500);
expect(await response!.text()).toBe(
"Could not get or create contact from database, email: alice@acme.com, sales: sales@company.com",
);
expect(consoleSpy).toHaveBeenCalled();
consoleSpy.mockRestore();
});
});
});

View File

@@ -0,0 +1,229 @@
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import type { Attachment } from "./extractAndUploadAttachments.ts";
import { MAIL_PROVIDERS } from "./mailProvider.const.ts";
export const getOrCreateCompanyFromDomain = async ({
domain,
salesId,
companyName,
website,
}: {
domain: string;
salesId: number;
companyName: string;
website: string;
}) => {
if (MAIL_PROVIDERS.includes(domain)) {
// We don't want to create companies for generic mail providers, as they are not really companies and it would pollute the database with useless entries.
return null;
}
// Check if the company already exists
const { data: existingCompany, error: fetchCompanyError } =
await supabaseAdmin
.from("companies")
.select("*")
.or(`website.eq.${website},name.eq.${companyName},name.eq.${domain}`)
.maybeSingle();
if (fetchCompanyError) {
throw new Error(
`Could not fetch companies from database, name: ${domain}, error: ${fetchCompanyError.message}`,
);
}
if (existingCompany) {
return existingCompany;
}
const { data: newCompanies, error: createCompanyError } = await supabaseAdmin
.from("companies")
.insert({ name: companyName, sales_id: salesId, website })
.select();
if (createCompanyError) {
throw new Error(
`Could not create company in database, domain: ${domain}, error: ${createCompanyError.message}`,
);
}
return newCompanies[0];
};
export const getOrCreateContactFromEmailInfo = async ({
email,
firstName,
lastName,
salesId,
domain,
companyName,
website,
}: {
email: string;
firstName: string;
lastName: string;
salesId: number;
domain: string;
companyName: string;
website: string;
}) => {
// Check if the contact already exists
const { data: existingContact, error: fetchContactError } =
await supabaseAdmin
.from("contacts")
.select("*")
.contains("email_jsonb", JSON.stringify([{ email }]))
.maybeSingle();
if (fetchContactError) {
throw new Error(
`Could not fetch contact from database, email: ${email}, error: ${fetchContactError.message}`,
);
}
if (existingContact) {
return existingContact;
}
const company = await getOrCreateCompanyFromDomain({
domain,
salesId,
companyName,
website,
});
// Create the contact
const { data: newContacts, error: createContactError } = await supabaseAdmin
.from("contacts")
.insert({
first_name: firstName,
last_name: lastName,
email_jsonb: [{ email, type: "Work" }],
company_id: company ? company.id : null,
sales_id: salesId,
first_seen: new Date(),
last_seen: new Date(),
tags: [],
})
.select();
if (createContactError || !newContacts[0]) {
throw new Error(
`Could not create contact in database, email: ${email}, error: ${createContactError?.message}`,
);
}
return newContacts[0];
};
export const findActiveSaleByEmail = async (salesEmail: string) => {
const normalizedEmail = salesEmail.trim().toLowerCase();
const byPrimaryEmail = await supabaseAdmin
.from("sales")
.select("*")
.eq("email", normalizedEmail)
.neq("disabled", true)
.maybeSingle();
if (byPrimaryEmail.error || byPrimaryEmail.data) {
return byPrimaryEmail;
}
const bySecondaryEmail = await supabaseAdmin
.from("sales")
.select("*")
.contains("secondary_emails", JSON.stringify([normalizedEmail]))
.neq("disabled", true)
.order("id", { ascending: true })
.limit(2);
if (bySecondaryEmail.error) {
return { data: null, error: bySecondaryEmail.error };
}
const matches = bySecondaryEmail.data ?? [];
if (matches.length > 1) {
console.error(
`Ambiguous sender ${normalizedEmail}: registered as a secondary email by sales ${matches
.map((sale: { id: number }) => sale.id)
.join(", ")}. Refusing to attribute the note.`,
);
return { data: null, error: null };
}
return { data: matches[0] ?? null, error: null };
};
export const addNoteToContact = async ({
sales,
salesEmail,
email,
domain,
firstName,
lastName,
noteContent,
attachments,
companyName,
website,
}: {
sales: { id: number };
salesEmail: string;
email: string;
domain: string;
firstName: string;
lastName: string;
noteContent: string;
attachments: Attachment[];
companyName: string;
website: string;
}) => {
const { contact, error } = await getOrCreateContactFromEmailInfo({
email,
firstName,
lastName,
salesId: sales.id,
domain,
companyName,
website,
})
.then((contact) => ({
contact,
}))
.catch((error) => {
return {
error,
};
});
if (error) {
console.error(
"Error in getOrCreateContactFromEmailInfo for email:",
email,
"sales:",
salesEmail,
"error:",
error,
);
return new Response(
`Could not get or create contact from database, email: ${email}, sales: ${salesEmail}`,
{ status: 500 },
);
}
// Add note to contact
const { error: createNoteError } = await supabaseAdmin
.from("contact_notes")
.insert({
contact_id: contact.id,
text: noteContent,
sales_id: sales.id,
attachments,
});
if (createNoteError) {
return new Response(
`Could not add note to contact ${email}, sales ${salesEmail}`,
{ status: 500 },
);
}
await supabaseAdmin
.from("contacts")
.update({ last_seen: new Date() })
.eq("id", contact.id);
};

View File

@@ -0,0 +1,6 @@
{
"imports": {
"base64-arraybuffer": "npm:base64-arraybuffer",
"tldts": "npm:tldts"
}
}

View File

@@ -0,0 +1,109 @@
import { decode } from "base64-arraybuffer";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
export type Attachment = {
title: string;
type: string;
path: string;
src: string;
};
/**
* Extracts the attachments from the email and upload them to Supabase Storage.
*
* Example:
* "Attachments": [
* {
* "Name": "test.txt",
* "Content": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
* "ContentType": "text/plain",
* "ContentLength": 45
* }
* ]
*
* Return Value:
* [{
* title: "test.txt",
* type: "text/plain",
* "path": "0.8262106278726917.txt",
* "src": "http://127.0.0.1:54321/storage/v1/object/public/attachments/0.8262106278726917.txt",
* }]
*
*/
export const extractAndUploadAttachments = async (
Attachments: {
Name: string;
Content: string;
ContentType: string;
ContentLength: number;
}[],
): Promise<Attachment[]> => {
return (
await Promise.all(
(Attachments || []).map(async (attachment) => {
const { Name, Content, ContentType } = attachment;
if (!Name || !Content || !ContentType) {
console.warn("Attachment is missing required fields, skipping", {
attachment,
});
return null;
}
const decodedContent = decode(Content);
if (!decodedContent) {
console.error("Failed to decode attachment content, skipping", {
attachment,
});
return null;
}
const fileParts = Name.split(".");
const fileExt = fileParts.length > 1 ? `.${Name.split(".").pop()}` : "";
const fileName = `${Math.random()}${fileExt}`;
const { error: uploadError } = await supabaseAdmin.storage
.from("attachments")
.upload(fileName, decodedContent);
if (uploadError) {
console.error("uploadError", uploadError);
throw new Error("Failed to upload attachment");
}
const { data } = supabaseAdmin.storage
.from("attachments")
.getPublicUrl(fileName);
return {
title: Name,
type: ContentType,
path: fileName,
src: fixPublicUrl(data.publicUrl),
};
}),
)
).filter(Boolean) as Attachment[];
};
/*
* Workaround fix for public URL not working on local environment
* See https://github.com/orgs/supabase/discussions/37271
*
* Replaces http://kong:8000/storage/v1/object/public/attachments/0.08968261048718773.txt with http://127.0.0.1:54321/storage/v1/object/public/attachments/0.08968261048718773.txt, using the SB_JWT_ISSUER env var (value http://127.0.0.1:54321/auth/v1) to get the external/public URL of the Supabase instance.
*/
const fixPublicUrl = (url: string) => {
const jwtIssuer = Deno.env.get("SB_JWT_ISSUER") ?? "";
const localUrl = jwtIssuer.replace("/auth/v1", "");
return url.replace("http://kong:8000", localUrl);
};
export const removeUploadedAttachments = async (attachments: Attachment[]) => {
if (!attachments.length) {
return;
}
const { error } = await supabaseAdmin.storage
.from("attachments")
.remove(attachments.map((attachment) => attachment.path));
if (error) {
console.error("Could not remove the uploaded attachments", error);
}
};

View File

@@ -0,0 +1,216 @@
import { describe, it, expect } from "vitest";
import {
extractMailContactData,
extractCompanyName,
} from "./extractMailContactData";
describe("extractMailContactData", () => {
it("extracts first name, last name, email, domain, company name, and website when Name is provided", () => {
expect(
extractMailContactData([
{
Email: "firstname.lastname@marmelab.com",
Name: "Firstname Lastname",
},
]),
).toEqual([
{
firstName: "Firstname",
lastName: "Lastname",
email: "firstname.lastname@marmelab.com",
domain: "marmelab.com",
companyName: "Marmelab",
website: "https://marmelab.com",
},
]);
});
it("derives name from email address when Name is empty", () => {
expect(
extractMailContactData([{ Email: "john.doe@example.com", Name: "" }]),
).toEqual([
{
firstName: "John",
lastName: "Doe",
email: "john.doe@example.com",
domain: "example.com",
companyName: "Example",
website: "https://example.com",
},
]);
});
it("sets empty firstName and capitalizes lastName when name has no space", () => {
expect(
extractMailContactData([{ Email: "alice@example.com", Name: "Alice" }]),
).toEqual([
{
firstName: "",
lastName: "Alice",
companyName: "Example",
website: "https://example.com",
email: "alice@example.com",
domain: "example.com",
},
]);
});
it("handles multi-part last name", () => {
expect(
extractMailContactData([
{
Email: "jean.de.la.fontaine@example.com",
Name: "Jean De La Fontaine",
},
]),
).toEqual([
{
firstName: "Jean",
lastName: "De La Fontaine",
companyName: "Example",
website: "https://example.com",
email: "jean.de.la.fontaine@example.com",
domain: "example.com",
},
]);
});
it("capitalizes names derived from email address", () => {
expect(
extractMailContactData([{ Email: "jane.smith@company.org", Name: "" }]),
).toEqual([
{
companyName: "Company",
website: "https://company.org",
firstName: "Jane",
lastName: "Smith",
email: "jane.smith@company.org",
domain: "company.org",
},
]);
});
it("capitalizes names derived from Name field", () => {
expect(
extractMailContactData([
{
Email: "jane.smith@company.org",
Name: "jane smith",
},
]),
).toEqual([
{
companyName: "Company",
website: "https://company.org",
firstName: "Jane",
lastName: "Smith",
email: "jane.smith@company.org",
domain: "company.org",
},
]);
});
it("processes multiple contacts", () => {
const result = extractMailContactData([
{ Email: "alice@foo.com", Name: "Alice Wonder" },
{ Email: "bob@bar.com", Name: "Bob Builder" },
]);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
firstName: "Alice",
lastName: "Wonder",
domain: "foo.com",
companyName: "Foo",
website: "https://foo.com",
});
expect(result[1]).toMatchObject({
firstName: "Bob",
lastName: "Builder",
domain: "bar.com",
companyName: "Bar",
website: "https://bar.com",
});
});
it("returns an empty array for empty input", () => {
expect(extractMailContactData([])).toEqual([]);
});
it("should lowercase email addresses", () => {
expect(
extractMailContactData([
{
Email: "Firstname.Lastname@marmelab.com",
Name: "Firstname Lastname",
},
]),
).toEqual([
{
firstName: "Firstname",
lastName: "Lastname",
email: "firstname.lastname@marmelab.com",
domain: "marmelab.com",
companyName: "Marmelab",
website: "https://marmelab.com",
},
]);
});
describe("extractCompanyName", () => {
it("extracts company name from lowercase domain", () => {
expect(extractCompanyName("marmelab.com")).toBe("Marmelab");
});
it("extracts company name from uppercase domain", () => {
expect(extractCompanyName("Marmelab.com")).toBe("Marmelab");
});
it("extracts company name from common free email provider", () => {
expect(extractCompanyName("gmail.com")).toBe("Gmail");
});
it("extracts company name from multi-part TLD (.co.uk)", () => {
expect(extractCompanyName("example.co.uk")).toBe("Example");
});
it("extracts company name with hyphen and converts to title case", () => {
expect(extractCompanyName("example-company.co.uk")).toBe(
"Example Company",
);
});
it("extracts company name with underscore and converts to title case", () => {
expect(extractCompanyName("Example_Company.com")).toBe("Example Company");
});
it("extracts company name with multiple hyphens", () => {
expect(extractCompanyName("my-super-cool-company.com")).toBe(
"My Super Cool Company",
);
});
it("extracts company name with multiple underscores", () => {
expect(extractCompanyName("my_super_cool_company.com")).toBe(
"My Super Cool Company",
);
});
it("extracts company name with mixed separators", () => {
expect(extractCompanyName("my-super_cool-company.com")).toBe(
"My Super Cool Company",
);
});
it("handles invalid or unknown TLD domains gracefully", () => {
expect(extractCompanyName("invalid.domain")).toBe("Invalid");
});
it("returns empty string for empty domain", () => {
expect(extractCompanyName("")).toBe("");
});
it("handles domain with no TLD separator", () => {
expect(extractCompanyName("nodots")).toBe("Nodots");
});
});
});

View File

@@ -0,0 +1,75 @@
import { parse } from "tldts";
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
export function extractCompanyName(domain: string) {
if (!domain) return "";
let name = domain.split(".").at(-2) || domain;
const parseResult = parse(domain);
if (parseResult.domainWithoutSuffix) {
name = parseResult.domainWithoutSuffix;
}
const humanizedName = name
.replace(/[-_]+/g, " ")
.split(" ")
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
.join(" ");
return humanizedName;
}
/**
* Extracts the first name, last name, email, and domain from a mail contact.
*
* Example:
* "ToFull": [
* {
* "Email": "firstname.lastname@marmelab.com",
* "Name": "Firstname Lastname"
* }
* ]
*
* Return Value:
* {
* firstName: "Firstname",
* lastName: "Lastname",
* email: "firstname.lastname@marmelab.com",
* domain: "marmelab.com"
* }
*
*/
export const extractMailContactData = (
ToFull: {
Email: string;
Name: string;
}[],
) => {
return ToFull.map((contact) => ({
...contact,
Email: (contact.Email || "").toLowerCase(),
})).map((contact) => {
const domain = (contact.Email.split("@").at(-1) || "").toLowerCase();
const fullName =
contact.Name ||
contact.Email.split("@").slice(0, -1).join(" ").split(".").join(" ");
let firstName = "";
let lastName = fullName;
if (fullName && fullName.includes(" ")) {
const parts = fullName.split(" ");
firstName = parts[0];
lastName = parts.slice(1).join(" ");
}
const companyName = extractCompanyName(domain);
const website = `https://${domain}`;
return {
firstName: capitalize(firstName),
lastName: capitalize(lastName),
email: contact.Email,
domain,
companyName,
website,
};
});
};

View File

@@ -0,0 +1,321 @@
import { describe, it, expect, vi } from "vitest";
import {
stripForwardingHeaderBlock,
stripSubjectForwardingPrefix,
stripMailSignature,
getForwardedMailContent,
} from "./forwardedParser";
describe("forwardedParser", () => {
afterEach(() => {
vi.restoreAllMocks();
});
describe("stripForwardingHeaderBlock", () => {
describe("returns original text when no forwarding separator is found", () => {
it("returns the original text for plain content", () => {
const text = "Hello, this is a regular email body.";
expect(stripForwardingHeaderBlock(text)).toBe(text);
});
it("trims whitespace when no separator is found", () => {
const text = " Hello world ";
expect(stripForwardingHeaderBlock(text)).toBe("Hello world");
});
it("returns original text when separator is not on the first line", () => {
const text =
"Some intro line\n---------- Forwarded message ----------\nActual content";
expect(stripForwardingHeaderBlock(text)).toBe(text.trim());
});
});
describe("Gmail forwarded separator", () => {
it("strips the Gmail forwarded header block", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
"Date: Mon, 24 Feb 2026 10:00:00 +0000",
"Subject: Meeting notes",
"To: Bob <bob@example.com>",
"",
"Hi Bob,",
"",
"Here are the meeting notes.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Hi Bob,\n\nHere are the meeting notes.",
);
});
});
describe("Apple Mail forwarded separator", () => {
it("strips the Apple Mail forwarded header block", () => {
const text = [
"Begin forwarded message:",
"From: Alice <alice@example.com>",
"Subject: Hello",
"Date: 24 Feb 2026",
"To: Bob <bob@example.com>",
"",
"This is the original message.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"This is the original message.",
);
});
});
describe("Outlook / Exchange forwarded separator", () => {
it("strips the Outlook forwarded header block", () => {
const text = [
"-----Original Message-----",
"From: Alice <alice@example.com>",
"Sent: Monday, February 24, 2026 10:00 AM",
"To: Bob <bob@example.com>",
"Subject: Project update",
"",
"Please find the update below.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Please find the update below.",
);
});
});
describe("French forwarded separators", () => {
it("strips the 'Message transféré' header block", () => {
const text = [
"---------- Message transféré ----------",
"De : Alice <alice@example.com>",
"Date : 24 févr. 2026",
"Objet : Réunion",
"",
"Bonjour,",
"",
"Voici le message.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Bonjour,\n\nVoici le message.",
);
});
it("strips the 'Transféré' header block", () => {
const text = [
"----- Transféré -----",
"De : Alice <alice@example.com>",
"",
"Le message transféré.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Le message transféré.");
});
it("strips the ASCII-variant 'Message transfere' header block", () => {
const text = [
"---------- Message transfere ----------",
"De : Alice <alice@example.com>",
"",
"Bonjour.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Bonjour.");
});
});
describe("German forwarded separator", () => {
it("strips the 'Weitergeleitete Nachricht' header block", () => {
const text = [
"---------- Weitergeleitete Nachricht ----------",
"Von: Alice <alice@example.com>",
"Datum: 24. Feb. 2026",
"Betreff: Projekt",
"",
"Hallo,",
"",
"Hier ist die Nachricht.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"Hallo,\n\nHier ist die Nachricht.",
);
});
});
describe("edge cases", () => {
it("strip everything when there is only one forwarded block and nothing after", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("");
});
it("preserves multi-paragraph body content", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@example.com>",
"",
"First paragraph.",
"",
"Second paragraph.",
"",
"Third paragraph.",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe(
"First paragraph.\n\nSecond paragraph.\n\nThird paragraph.",
);
});
it("trims leading and trailing whitespace from the result", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice",
"",
" ",
" Body content ",
" ",
].join("\n");
expect(stripForwardingHeaderBlock(text)).toBe("Body content");
});
});
});
describe("stripSubjectForwardingPrefix", () => {
it("strips 'Fwd:' prefix", () => {
expect(stripSubjectForwardingPrefix("Fwd: Hello")).toBe("Hello");
});
it("strips 'FW:' prefix", () => {
expect(stripSubjectForwardingPrefix("FW: Hello")).toBe("Hello");
});
it("strips 'FWD:' prefix", () => {
expect(stripSubjectForwardingPrefix("FWD: Hello")).toBe("Hello");
});
it("strips 'Tr:' prefix (French)", () => {
expect(stripSubjectForwardingPrefix("Tr: Bonjour")).toBe("Bonjour");
});
it("strips 'SV:' prefix (Scandinavian)", () => {
expect(stripSubjectForwardingPrefix("SV: Hei")).toBe("Hei");
});
it("strips 'VS:' prefix", () => {
expect(stripSubjectForwardingPrefix("VS: Hallo")).toBe("Hallo");
});
it("strips 'WG:' prefix (German)", () => {
expect(stripSubjectForwardingPrefix("WG: Hallo")).toBe("Hallo");
});
it("is case-insensitive", () => {
expect(stripSubjectForwardingPrefix("fwd: lowercase")).toBe("lowercase");
expect(stripSubjectForwardingPrefix("FWD - Mixed")).toBe("Mixed");
});
it("returns the subject unchanged when no prefix is present", () => {
expect(stripSubjectForwardingPrefix("Meeting notes")).toBe(
"Meeting notes",
);
});
it("handles dash separator instead of colon", () => {
expect(stripSubjectForwardingPrefix("Fwd - My subject")).toBe(
"My subject",
);
});
it("should return original subject if stripping results in empty string", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const subject = "Fwd: ";
expect(stripSubjectForwardingPrefix(subject)).toBe(subject);
});
});
describe("stripMailSignature", () => {
it("strips the mail signature when separator is present", () => {
const text = "Hello,\nThis is the email body.\n-- \nSignature here";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
it("returns original text when no signature separator is found", () => {
const text = "Hello,\nThis is the email body.\nBest regards.";
expect(stripMailSignature(text)).toBe(text);
});
it("handles multiple occurrences of the signature separator", () => {
const text =
"Hello,\nThis is the email body.\n-- \nFirst signature\n-- \nSecond signature";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
it("trims whitespace from the result", () => {
const text = "Hello,\nThis is the email body.\n-- \nSignature here ";
expect(stripMailSignature(text)).toBe("Hello,\nThis is the email body.");
});
});
describe("getForwardedMailContent", () => {
it("strips both the forwarding header block and the mail signature", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"Hi Bob,",
"This is the original message.",
"-- \nAlice's signature",
].join("\n");
expect(getForwardedMailContent(text)).toBe(
"Hi Bob,\nThis is the original message.",
);
});
it("returns original text when stripping results in empty content", () => {
vi.spyOn(console, "warn").mockImplementation(() => {});
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"-- \nAlice's signature",
].join("\n");
// Both stripping operations would result in empty content, so original text should be returned.
expect(getForwardedMailContent(text)).toBe(text.trim());
});
it("handles text with no forwarding header and no signature", () => {
const text = "Hello,\nThis is a simple email body.";
expect(getForwardedMailContent(text)).toBe(text);
});
it("handles text with only a forwarding header", () => {
const text = [
"---------- Forwarded message ----------",
"From: Alice <alice@wonderland.com>",
"",
"This is the original message.",
].join("\n");
expect(getForwardedMailContent(text)).toBe(
"This is the original message.",
);
});
it("handles text with only a mail signature", () => {
const text = "Hello,\nThis is the email body.\n-- \nBest regards.";
expect(getForwardedMailContent(text)).toBe(
"Hello,\nThis is the email body.",
);
});
});
});

View File

@@ -0,0 +1,83 @@
/** Patterns that mark the beginning of a forwarded block in the email body. */
const FORWARD_SEPARATOR_PATTERNS = [
// Gmail
/^-{5,}\s*Forwarded message\s*-{5,}/im,
// Apple Mail
/^Begin forwarded message:/im,
// Outlook / Exchange
/^-{5,}\s*Original Message\s*-{5,}/im,
// French clients (Transféré / Message transféré)
/^-{5,}\s*Message transf[eé]r[eé]\s*-{5,}/im,
/^-{5,}\s*Transf[eé]r[eé]\s*-{5,}/im,
// German (Weitergeleitet)
/^-{5,}\s*Weitergeleitete Nachricht\s*-{5,}/im,
];
export const stripForwardingHeaderBlock = (text: string): string => {
const lines = text.split("\n");
let bodyStartIndex = 0;
// First check that the first line matches a known forwarding separator pattern
const hasForwardedSeparator = FORWARD_SEPARATOR_PATTERNS.some((pattern) => {
const match = lines[0].match(pattern);
return !!match;
});
if (!hasForwardedSeparator) {
// No known forwarding pattern detected, return the original text
return text.trim();
}
// Walk through the header-like lines at the top of the block
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Blank line signals end of the forwarded header block
if (line.trim() === "") {
bodyStartIndex = i + 1;
break;
}
i++;
}
// No blank line found after the header → no body to extract
if (bodyStartIndex === 0) {
return "";
}
return lines.slice(bodyStartIndex).join("\n").trim();
};
export const stripSubjectForwardingPrefix = (subject: string): string => {
const result = subject
.replace(/^(Fwd|FW|FWD|Tr|SV|VS|WG|WG:)\s*[:-]\s*/i, "")
.trim();
if (result.length === 0) {
console.warn(
`Stripping forwarding prefix from subject "${subject}" resulted in empty string, returning original subject`,
);
return subject;
}
return result;
};
export const stripMailSignature = (text: string): string => {
const signatureSeparatorIndex = text.indexOf("\n-- \n");
if (signatureSeparatorIndex !== -1) {
return text.substring(0, signatureSeparatorIndex).trim();
}
return text.trim();
};
export const getForwardedMailContent = (body: string): string => {
const strippedBody = stripForwardingHeaderBlock(stripMailSignature(body));
if (strippedBody.length === 0) {
console.warn(
"Stripping mail signature and forwarded header block resulted in empty note content, returning original body",
);
return body.trim();
}
return strippedBody;
};

View File

@@ -0,0 +1,4 @@
export const getExpectedAuthorization = (
webhookUser: string,
webhookPassword: string,
) => `Basic ${btoa(`${webhookUser}:${webhookPassword}`)}`;

View File

@@ -0,0 +1,6 @@
export const getNoteContent = (
subject: string,
strippedText: string,
) => `${subject}
${strippedText}`;

View File

@@ -0,0 +1,393 @@
// Follow this setup guide to integrate the Deno language server with your editor:
// https://deno.land/manual/getting_started/setup_your_environment
// This enables autocomplete, go to definition, etc.
// Setup type definitions for built-in Supabase Runtime APIs
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { addNoteToContact, findActiveSaleByEmail } from "./addNoteToContact.ts";
import {
getForwardedMailContent,
stripSubjectForwardingPrefix,
} from "./forwardedParser.ts";
import { extractMailContactData } from "./extractMailContactData.ts";
import { getExpectedAuthorization } from "./getExpectedAuthorization.ts";
import { getNoteContent } from "./getNoteContent.ts";
import {
extractAndUploadAttachments,
removeUploadedAttachments,
} from "./extractAndUploadAttachments.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
const webhookUser = Deno.env.get("POSTMARK_WEBHOOK_USER");
const webhookPassword = Deno.env.get("POSTMARK_WEBHOOK_PASSWORD");
const INBOUND_EMAIL = (Deno.env.get("VITE_INBOUND_EMAIL") || "").toLowerCase();
if (!webhookUser || !webhookPassword) {
throw new Error(
"Missing POSTMARK_WEBHOOK_USER or POSTMARK_WEBHOOK_PASSWORD env variable",
);
}
const rawAuthorizedIPs = Deno.env.get("POSTMARK_WEBHOOK_AUTHORIZED_IPS");
if (!rawAuthorizedIPs) {
throw new Error("Missing POSTMARK_WEBHOOK_AUTHORIZED_IPS env variable");
}
Deno.serve(async (req) => {
let response: Response | undefined;
response = checkRequestTypeAndHeaders(req);
if (response) return response;
const json = await req.json();
response = checkBody(json);
if (response) return response;
const { FromFull, Attachments } = json;
let { ToFull, TextBody, Subject } = json;
const salesEmail = (FromFull.Email || "").toLowerCase();
if (!salesEmail) {
// Return a 403 to let Postmark know that it's no use to retry this request
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
return new Response(
`Could not extract sales email from FromFull: ${FromFull}`,
{ status: 403 },
);
}
const allSales = await supabaseAdmin
.from("sales")
.select("email, secondary_emails");
if (allSales.error) {
console.error("Could not fetch sales emails:", allSales.error);
return new Response("Could not fetch sales from database", { status: 500 });
}
const salesEmails =
allSales.data
?.flatMap((s: { email: string; secondary_emails: string[] }) => [
s.email,
...s.secondary_emails,
])
.map((email: string) => email.toLowerCase()) ?? [];
const firstToEmail = (ToFull[0]?.Email || "").toLowerCase();
// If we have an INBOUND_EMAIL and the email is sent only to the inbound email address, and the sender is a known sales email,
// then we can try to extract the real recipient email from the body of the email
if (
INBOUND_EMAIL &&
ToFull.length === 1 &&
firstToEmail === INBOUND_EMAIL &&
salesEmails.includes(salesEmail)
) {
const emailRegex = /[\w.+%-]+@[\w.-]+\.[a-zA-Z]{2,}/g;
const emailsInBody = TextBody.match(emailRegex) || [];
const candidateEmails = emailsInBody
.map((email: string) => email.toLowerCase())
.filter(
(email: string) =>
email !== INBOUND_EMAIL && !salesEmails?.includes(email),
);
if (candidateEmails.length > 0) {
ToFull = [
{
Email: candidateEmails[0],
Name: "",
},
];
} else {
// Return a 403 to let Postmark know that it's no use to retry this request
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
return new Response(
`Could not extract recipient email from transferred email body.`,
{ status: 403 },
);
}
TextBody = getForwardedMailContent(TextBody);
Subject = stripSubjectForwardingPrefix(Subject);
}
const noteContent = getNoteContent(Subject, TextBody);
const contacts = extractMailContactData(ToFull);
const { data: sales, error: fetchSalesError } =
await findActiveSaleByEmail(salesEmail);
if (fetchSalesError) {
console.error("Could not fetch the sender's sale:", fetchSalesError);
return new Response(
`Could not fetch sales from database, email: ${salesEmail}`,
{ status: 500 },
);
}
if (!sales) {
// Return a 403 to let Postmark know that it's no use to retry this request
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
return new Response(
`Unable to find (active) sales in database, email: ${salesEmail}`,
{ status: 403 },
);
}
const attachments = await extractAndUploadAttachments(Attachments);
const failedContacts: string[] = [];
for (const {
firstName,
lastName,
email,
domain,
companyName,
website,
} of contacts) {
if (!email) {
// Return a 403 to let Postmark know that it's no use to retry this request
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
return new Response(`Could not extract email from ToFull: ${ToFull}`, {
status: 403,
});
}
const errorResponse = await addNoteToContact({
sales,
salesEmail,
email,
domain,
firstName,
lastName,
noteContent,
attachments,
companyName,
website,
});
if (errorResponse) {
console.error(
`Could not add the note for ${email}: ${await errorResponse.text()}`,
);
failedContacts.push(email);
}
}
if (failedContacts.length) {
// A partial failure answers 403 so Postmark does not redeliver: the notes
// already written are not idempotent and would be duplicated. When every
// contact failed there is no note to duplicate, so a retry is safe.
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
const status = failedContacts.length === contacts.length ? 500 : 403;
if (status === 500) {
await removeUploadedAttachments(attachments);
}
return new Response(
`Could not add the note for: ${failedContacts.join(", ")}`,
{ status },
);
}
return new Response("OK");
});
const checkRequestTypeAndHeaders = (req: Request) => {
// Only allow known IP addresses
// We can use the x-forwarded-for header as it is populated by Supabase
// https://supabase.com/docs/guides/api/securing-your-api#accessing-request-information
const forwardedFor = req.headers.get("x-forwarded-for");
if (!forwardedFor) {
return new Response("Unauthorized", { status: 401 });
}
const ips = forwardedFor.split(",").map((ip) => ip.trim());
const authorizedIPs = rawAuthorizedIPs
.split(",")
.map((ip: string) => ip.trim());
if (!ips.some((ip) => authorizedIPs.includes(ip))) {
return new Response("Unauthorized", { status: 401 });
}
// Only allow POST requests
if (req.method !== "POST") {
return new Response(null, { status: 405 });
}
// Check the Authorization header
const expectedAuthorization = getExpectedAuthorization(
webhookUser,
webhookPassword,
);
const authorization = req.headers.get("Authorization");
if (authorization !== expectedAuthorization) {
return new Response("Unauthorized", { status: 401 });
}
};
// deno-lint-ignore no-explicit-any
const checkBody = (json: any) => {
const { ToFull, FromFull, Subject, TextBody } = json;
// In case of incorrect request data, we
// return a 403 to let Postmark know that it's no use to retry this request
// https://postmarkapp.com/developer/webhooks/inbound-webhook#errors-and-retries
if (!ToFull || !ToFull.length)
return new Response("Missing parameter: ToFull", { status: 403 });
if (!FromFull)
return new Response("Missing parameter: FromFull", { status: 403 });
if (!Subject)
return new Response("Missing parameter: Subject", { status: 403 });
if (!TextBody)
return new Response("Missing parameter: TextBody", {
status: 403,
});
};
/* To invoke locally:
1. Run `make start`
2. Make sure to have a Sales with email "support@postmarkapp.com" (create it if needed)
3. OPTIONAL: Create a Contact with email "firstname.lastname@marmelab.com"
4. In another terminal, run `make start-supabase-functions`
5. In another terminal, make an HTTP request:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/postmark' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic dGVzdHVzZXI6dGVzdHB3ZA==' \
--data '{
"FromName": "Postmarkapp Support",
"From": "support@postmarkapp.com",
"FromFull": {
"Email": "support@postmarkapp.com",
"Name": "Postmarkapp Support",
"MailboxHash": ""
},
"To": "\"Firstname Lastname\" <firstname.lastname@marmelab.com>",
"ToFull": [
{
"Email": "firstname.lastname@marmelab.com",
"Name": "Firstname Lastname",
"MailboxHash": "SampleHash"
}
],
"Cc": "\"First Cc\" <firstcc@postmarkapp.com>, secondCc@postmarkapp.com",
"CcFull": [
{
"Email": "firstcc@postmarkapp.com",
"Name": "First Cc",
"MailboxHash": ""
},
{
"Email": "secondCc@postmarkapp.com",
"Name": "",
"MailboxHash": ""
}
],
"Bcc": "\"First Bcc\" <firstbcc@postmarkapp.com>, secondbcc@postmarkapp.com",
"BccFull": [
{
"Email": "firstbcc@postmarkapp.com",
"Name": "First Bcc",
"MailboxHash": ""
},
{
"Email": "secondbcc@postmarkapp.com",
"Name": "",
"MailboxHash": ""
}
],
"OriginalRecipient": "firstname.lastname@marmelab.com",
"Subject": "Test subject",
"MessageID": "73e6d360-66eb-11e1-8e72-a8904824019b",
"ReplyTo": "replyto@postmarkapp.com",
"MailboxHash": "SampleHash",
"Date": "Fri, 1 Aug 2014 16:45:32 -04:00",
"TextBody": "This is a test text body.",
"HtmlBody": "<html><body><p>This is a test html body.</p></body></html>",
"StrippedTextReply": "This is the reply text",
"Tag": "TestTag",
"Headers": [
{
"Name": "X-Header-Test",
"Value": ""
},
{
"Name": "X-Spam-Status",
"Value": "No"
},
{
"Name": "X-Spam-Score",
"Value": "-0.1"
},
{
"Name": "X-Spam-Tests",
"Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS"
}
],
"Attachments": [
{
"Name": "test.txt",
"Content": "VGhpcyBpcyBhdHRhY2htZW50IGNvbnRlbnRzLCBiYXNlLTY0IGVuY29kZWQu",
"ContentType": "text/plain",
"ContentLength": 45
}
]
}'
To trigger the email forwarding feature, you can change the "To" and "ToFull" fields to have the INBOUND_EMAIL, and add an email address that is neither a sales nor the INBOUND_EMAIL, for example:
curl -i --location --request POST 'http://127.0.0.1:54321/functions/v1/postmark' \
--header 'Content-Type: application/json' \
--header 'Authorization: Basic dGVzdHVzZXI6dGVzdHB3ZA==' \
--data '{
"FromName": "Postmarkapp Support",
"MessageStream": "inbound",
"From": "support@postmarkapp.com",
"FromFull": {
"Email": "support@postmarkapp.com",
"Name": "Postmarkapp Support",
"MailboxHash": ""
},
"To": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
"ToFull": [
{
"Email": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
"Name": "",
"MailboxHash": ""
}
],
"Cc": "",
"CcFull": [],
"Bcc": "",
"BccFull": [],
"OriginalRecipient": "2aff30e603e54dc3eb556bd9e03ee099@inbound.postmarkapp.com",
"Subject": "Fwd: Test for forwarding mail",
"MessageID": "32dcbecb-57d0-476e-9591-c747808cb599",
"ReplyTo": "",
"MailboxHash": "",
"Date": "Thu, 5 Mar 2026 10:41:26 +0100",
"TextBody": "---------- Forwarded message ---------\nFrom : Original Recipient <original.recipient@company.com>\nDate: Fri, 1 Aug 2014 16:45:32 -04:00\nSubject: Test for forwarding mail\nTo: Postmarkapp Support <support@postmarkapp.com>\n\n\nThe transferred message body\n",
"HtmlBody": "<div dir=\"ltr\"><br><br><div class=\"gmail_quote gmail_quote_container\"><div dir=\"ltr\" class=\"gmail_attr\">---------- Forwarded message ---------<br>From: <strong class=\"gmail_sendername\" dir=\"auto\">Original Recipient</strong> <span dir=\"auto\">&lt;<a href=\"mailto:original.recipient@company.com\">original.recipient@company.com</a>&gt;</span><br>Date: Fri, 1 Aug 2014 16:45:32 -04:00<br>Subject: Test for forwarding mail<br>To: Postmarkapp Support &lt;<a href=\"mailto:support@postmarkapp.com\">support@postmarkapp.com</a>&gt;<br></div><br><br><div dir=\"ltr\">The transferred message body</div>\n</div></div>\n",
"StrippedTextReply": "",
"Tag": "",
"Headers": [
{
"Name": "X-Header-Test",
"Value": ""
},
{
"Name": "X-Spam-Status",
"Value": "No"
},
{
"Name": "X-Spam-Score",
"Value": "-0.1"
},
{
"Name": "X-Spam-Tests",
"Value": "DKIM_SIGNED,DKIM_VALID,DKIM_VALID_AU,SPF_PASS"
}
],
"Attachments": []
}'
*/

View File

@@ -0,0 +1,104 @@
export const MAIL_PROVIDERS = [
"gmail.com",
"yahoo.com",
"hotmail.com",
"aol.com",
"hotmail.co.uk",
"hotmail.fr",
"msn.com",
"yahoo.fr",
"wanadoo.fr",
"orange.fr",
"comcast.net",
"yahoo.co.uk",
"yahoo.com.br",
"yahoo.co.in",
"live.com",
"rediffmail.com",
"free.fr",
"gmx.de",
"web.de",
"yandex.ru",
"ymail.com",
"libero.it",
"outlook.com",
"uol.com.br",
"bol.com.br",
"mail.ru",
"cox.net",
"hotmail.it",
"sbcglobal.net",
"sfr.fr",
"live.fr",
"verizon.net",
"live.co.uk",
"googlemail.com",
"yahoo.es",
"ig.com.br",
"live.nl",
"bigpond.com",
"terra.com.br",
"yahoo.it",
"neuf.fr",
"yahoo.de",
"alice.it",
"rocketmail.com",
"att.net",
"laposte.net",
"facebook.com",
"bellsouth.net",
"yahoo.in",
"hotmail.es",
"charter.net",
"yahoo.ca",
"yahoo.com.au",
"rambler.ru",
"hotmail.de",
"tiscali.it",
"shaw.ca",
"yahoo.co.jp",
"sky.com",
"earthlink.net",
"optonline.net",
"freenet.de",
"t-online.de",
"aliceadsl.fr",
"virgilio.it",
"home.nl",
"qq.com",
"telenet.be",
"me.com",
"yahoo.com.ar",
"tiscali.co.uk",
"yahoo.com.mx",
"voila.fr",
"gmx.net",
"mail.com",
"planet.nl",
"tin.it",
"live.it",
"ntlworld.com",
"arcor.de",
"yahoo.co.id",
"frontiernet.net",
"hetnet.nl",
"live.com.au",
"yahoo.com.sg",
"zonnet.nl",
"club-internet.fr",
"juno.com",
"optusnet.com.au",
"blueyonder.co.uk",
"bluewin.ch",
"skynet.be",
"sympatico.ca",
"windstream.net",
"mac.com",
"centurytel.net",
"chello.nl",
"live.ca",
"aim.com",
"bigpond.net.au",
"online.de",
"apple.com",
];

View File

@@ -0,0 +1,38 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
async function updatePassword(user: any) {
const { data, error } = await supabaseAdmin.auth.resetPasswordForEmail(
user.email,
);
if (!data || error) {
return createErrorResponse(500, "Internal Server Error");
}
return new Response(
JSON.stringify({
data,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
if (req.method === "PATCH") {
return updatePassword(user);
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

View File

@@ -0,0 +1,435 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { supabaseAdmin } from "../_shared/supabaseAdmin.ts";
import { corsHeaders, OptionsMiddleware } from "../_shared/cors.ts";
import { createErrorResponse } from "../_shared/utils.ts";
import { AuthMiddleware, UserMiddleware } from "../_shared/authentication.ts";
import { getUserSale } from "../_shared/getUserSale.ts";
import {
findInvalidEmail,
MAX_SECONDARY_EMAILS,
normalizeSecondaryEmails,
} from "./secondaryEmails.ts";
async function updateSaleDisabled(user_id: string, disabled: boolean) {
return await supabaseAdmin
.from("sales")
.update({ disabled: disabled ?? false })
.eq("user_id", user_id);
}
async function updateSaleAdministrator(
user_id: string,
administrator: boolean,
) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.update({ administrator })
.eq("user_id", user_id)
.select("*");
if (!sales?.length || salesError) {
console.error("Error updating user:", salesError);
throw salesError ?? new Error("Failed to update sale");
}
return sales.at(0);
}
async function createSale(
user_id: string,
data: {
email: string;
password: string;
first_name: string;
last_name: string;
disabled: boolean;
administrator: boolean;
},
) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.insert({ ...data, user_id })
.select("*");
if (!sales?.length || salesError) {
console.error("Error creating user:", salesError);
throw salesError ?? new Error("Failed to create sale");
}
return sales.at(0);
}
async function findEmailUsedByAnotherSale(
emails: string[],
excludeSalesId?: number,
) {
const isTakenBy = async (
column: "email" | "secondary_emails",
email: string,
) => {
const selected = supabaseAdmin.from("sales").select("id");
let query =
column === "email"
? selected.eq("email", email)
: selected.contains("secondary_emails", JSON.stringify([email]));
if (excludeSalesId !== undefined) {
query = query.neq("id", excludeSalesId);
}
const { data: matches, error: salesError } = await query.limit(1);
if (salesError) {
console.error("Error fetching sales:", salesError);
throw salesError;
}
return Boolean(matches?.length);
};
for (const email of emails) {
if (
(await isTakenBy("email", email)) ||
(await isTakenBy("secondary_emails", email))
) {
return email;
}
}
return undefined;
}
async function updateSaleSecondaryEmails(
user_id: string,
secondary_emails: string[],
) {
const { error: salesError } = await supabaseAdmin
.from("sales")
.update({ secondary_emails })
.eq("user_id", user_id);
if (salesError) {
console.error("Error updating user:", salesError);
throw salesError;
}
}
async function updateSaleAvatar(user_id: string, avatar: string) {
const { data: sales, error: salesError } = await supabaseAdmin
.from("sales")
.update({ avatar })
.eq("user_id", user_id)
.select("*");
if (!sales?.length || salesError) {
console.error("Error updating user:", salesError);
throw salesError ?? new Error("Failed to update sale");
}
return sales.at(0);
}
async function inviteUser(req: Request, currentUserSale: any) {
const { email, password, first_name, last_name, disabled, administrator } =
await req.json();
if (!currentUserSale.administrator) {
return createErrorResponse(401, "Not Authorized");
}
const takenEmail = await findEmailUsedByAnotherSale(
email ? [email.trim().toLowerCase()] : [],
);
if (takenEmail) {
return createErrorResponse(
409,
`Email already used by another user: ${takenEmail}`,
{ code: "email_taken", email: takenEmail },
);
}
const { data, error: userError } = await supabaseAdmin.auth.admin.createUser({
email,
password,
user_metadata: { first_name, last_name },
});
let user = data?.user;
if (!user && userError?.code === "email_exists") {
// This may happen if users cleared their database but not the users
// We have to create the sale directly
const { data, error } = await supabaseAdmin.rpc("get_user_id_by_email", {
email,
});
if (!data || error) {
console.error(
`Error inviting user: error=${error ?? "could not fetch users for email"}`,
);
return createErrorResponse(500, "Internal Server Error");
}
user = data[0];
try {
const { data: existingSale, error: salesError } = await supabaseAdmin
.from("sales")
.select("*")
.eq("user_id", user.id);
if (salesError) {
return createErrorResponse(salesError.status, salesError.message, {
code: salesError.code,
});
}
if (existingSale.length > 0) {
return createErrorResponse(
400,
"A sales for this email already exists",
);
}
const sale = await createSale(user.id, {
email,
password,
first_name,
last_name,
disabled,
administrator,
});
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
} catch (error) {
return createErrorResponse(
(error as any).status ?? 500,
(error as Error).message,
{
code: (error as any).code,
},
);
}
} else {
if (userError) {
console.error(`Error inviting user: user_error=${userError}`);
return createErrorResponse(userError.status, userError.message, {
code: userError.code,
});
}
if (!data?.user) {
console.error("Error inviting user: undefined user");
return createErrorResponse(500, "Internal Server Error");
}
const { error: emailError } =
await supabaseAdmin.auth.admin.inviteUserByEmail(email);
if (emailError) {
console.error(`Error inviting user, email_error=${emailError}`);
return createErrorResponse(500, "Failed to send invitation mail");
}
}
try {
await updateSaleDisabled(user.id, disabled);
const sale = await updateSaleAdministrator(user.id, administrator);
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: { "Content-Type": "application/json", ...corsHeaders },
},
);
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
}
async function patchUser(req: Request, currentUserSale: any) {
const {
sales_id,
email,
secondary_emails,
first_name,
last_name,
avatar,
administrator,
disabled,
} = await req.json();
const { data: sale } = await supabaseAdmin
.from("sales")
.select("*")
.eq("id", sales_id)
.single();
if (!sale) {
return createErrorResponse(404, "Not Found");
}
// Users can only update their own profile unless they are an administrator
if (!currentUserSale.administrator && currentUserSale.id !== sale.id) {
return createErrorResponse(401, "Not Authorized");
}
if (email && email.trim().toLowerCase() !== sale.email.toLowerCase()) {
const takenEmail = await findEmailUsedByAnotherSale(
[email.trim().toLowerCase()],
sales_id,
);
if (takenEmail) {
return createErrorResponse(
409,
`Email already used by another user: ${takenEmail}`,
{ code: "email_taken", email: takenEmail },
);
}
}
let normalizedSecondaryEmails: string[] | undefined;
if (secondary_emails !== undefined) {
normalizedSecondaryEmails = normalizeSecondaryEmails(secondary_emails);
if (!normalizedSecondaryEmails) {
return createErrorResponse(400, "secondary_emails must be an array", {
code: "invalid_secondary_emails_payload",
});
}
if (normalizedSecondaryEmails.length > MAX_SECONDARY_EMAILS) {
return createErrorResponse(
400,
`No more than ${MAX_SECONDARY_EMAILS} secondary emails are allowed`,
{ code: "too_many_secondary_emails" },
);
}
const invalidEmail = findInvalidEmail(normalizedSecondaryEmails);
if (invalidEmail) {
return createErrorResponse(
400,
`Invalid secondary email: ${invalidEmail}`,
{ code: "invalid_secondary_email", email: invalidEmail },
);
}
const takenEmail = await findEmailUsedByAnotherSale(
normalizedSecondaryEmails,
sales_id,
);
if (takenEmail) {
return createErrorResponse(
409,
`Secondary email already used by another user: ${takenEmail}`,
{ code: "secondary_email_taken", email: takenEmail },
);
}
}
const primaryEmail = (email ?? sale.email).trim().toLowerCase();
const effectiveSecondaryEmails =
normalizedSecondaryEmails ??
(Array.isArray(sale.secondary_emails) ? sale.secondary_emails : []);
if (effectiveSecondaryEmails.includes(primaryEmail)) {
return createErrorResponse(
409,
`Secondary email is already the main address: ${primaryEmail}`,
{ code: "secondary_email_is_primary", email: primaryEmail },
);
}
const { data, error: userError } =
await supabaseAdmin.auth.admin.updateUserById(sale.user_id, {
email,
ban_duration: disabled ? "87600h" : "none",
user_metadata: { first_name, last_name },
});
if (!data?.user || userError) {
console.error("Error patching user:", userError);
return createErrorResponse(500, "Internal Server Error");
}
try {
if (avatar) {
await updateSaleAvatar(data.user.id, avatar);
}
if (normalizedSecondaryEmails) {
await updateSaleSecondaryEmails(data.user.id, normalizedSecondaryEmails);
}
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
// Only administrators can update the administrator and disabled status
if (!currentUserSale.administrator) {
const { data: new_sale } = await supabaseAdmin
.from("sales")
.select("*")
.eq("id", sales_id)
.single();
return new Response(
JSON.stringify({
data: new_sale,
}),
{
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
},
);
}
try {
await updateSaleDisabled(data.user.id, disabled);
const sale = await updateSaleAdministrator(data.user.id, administrator);
return new Response(
JSON.stringify({
data: sale,
}),
{
headers: {
"Content-Type": "application/json",
...corsHeaders,
},
},
);
} catch (e) {
console.error("Error patching sale:", e);
return createErrorResponse(500, "Internal Server Error");
}
}
Deno.serve(async (req: Request) =>
OptionsMiddleware(req, async (req) =>
AuthMiddleware(req, async (req) =>
UserMiddleware(req, async (req, user) => {
const currentUserSale = await getUserSale(user);
if (!currentUserSale) {
return createErrorResponse(401, "Unauthorized");
}
try {
if (req.method === "POST") {
return await inviteUser(req, currentUserSale);
}
if (req.method === "PATCH") {
return await patchUser(req, currentUserSale);
}
} catch (e) {
console.error("Unhandled error in the users function:", e);
return createErrorResponse(500, "Internal Server Error");
}
return createErrorResponse(405, "Method Not Allowed");
}),
),
),
);

View File

@@ -0,0 +1,74 @@
// @vitest-environment node
import { describe, it, expect } from "vitest";
import { findInvalidEmail, normalizeSecondaryEmails } from "./secondaryEmails";
describe("normalizeSecondaryEmails", () => {
it("returns undefined for a non-array, so the caller rejects instead of wiping", () => {
expect(normalizeSecondaryEmails(undefined)).toBe(undefined);
expect(normalizeSecondaryEmails(null)).toBe(undefined);
expect(normalizeSecondaryEmails("john@perso.com")).toBe(undefined);
expect(normalizeSecondaryEmails({ 0: "john@perso.com" })).toBe(undefined);
});
it("returns an empty array for an empty array, which clears the addresses", () => {
expect(normalizeSecondaryEmails([])).toEqual([]);
});
it("trims and lowercases every address", () => {
expect(normalizeSecondaryEmails([" John@Perso.COM "])).toEqual([
"john@perso.com",
]);
});
it("removes addresses that differ only by case or spacing", () => {
expect(
normalizeSecondaryEmails(["john@perso.com", " JOHN@perso.com "]),
).toEqual(["john@perso.com"]);
});
it("drops empty entries and non-string values", () => {
expect(
normalizeSecondaryEmails(["john@perso.com", "", " ", 42, null]),
).toEqual(["john@perso.com"]);
});
it("preserves the order of the remaining addresses", () => {
expect(
normalizeSecondaryEmails(["b@perso.com", "a@perso.com", "c@perso.com"]),
).toEqual(["b@perso.com", "a@perso.com", "c@perso.com"]);
});
});
describe("findInvalidEmail", () => {
it("returns undefined when every address is well formed", () => {
expect(
findInvalidEmail(["john@perso.com", "j.doe+tag@sub.domain.co.uk"]),
).toBe(undefined);
});
it("returns undefined for an empty list", () => {
expect(findInvalidEmail([])).toBe(undefined);
});
it("reports an address with no domain", () => {
expect(findInvalidEmail(["john@perso.com", "not-an-email"])).toBe(
"not-an-email",
);
});
it("reports an address with no top level domain", () => {
expect(findInvalidEmail(["john@localhost"])).toBe("john@localhost");
});
it("reports an address containing a space", () => {
expect(findInvalidEmail(["john doe@perso.com"])).toBe("john doe@perso.com");
});
it("reports the first offending address so the caller can name it", () => {
expect(findInvalidEmail(["bad-one", "bad-two"])).toBe("bad-one");
});
it("rejects a comma, which would split the PostgREST or() filter", () => {
expect(findInvalidEmail(["john,doe@perso.com"])).toBe("john,doe@perso.com");
});
});

View File

@@ -0,0 +1,22 @@
const EMAIL_PATTERN = /^[^\s@,]+@[^\s@,]+\.[^\s@,]+$/;
export const MAX_SECONDARY_EMAILS = 10;
export const normalizeSecondaryEmails = (
secondaryEmails: unknown,
): string[] | undefined => {
if (!Array.isArray(secondaryEmails)) {
return undefined;
}
return [
...new Set(
secondaryEmails
.filter((email): email is string => typeof email === "string")
.map((email) => email.trim().toLowerCase())
.filter(Boolean),
),
];
};
export const findInvalidEmail = (emails: string[]): string | undefined =>
emails.find((email) => !EMAIL_PATTERN.test(email));

View File

@@ -0,0 +1,600 @@
create table "public"."companies" (
"id" bigint generated by default as identity not null,
"created_at" timestamp with time zone not null default now(),
"name" text not null,
"sector" text,
"size" smallint,
"linkedin_url" text,
"website" text,
"phone_number" text,
"address" text,
"zipcode" text,
"city" text,
"stateAbbr" text,
"sales_id" bigint,
"context_links" json,
"country" text,
"description" text,
"revenue" text,
"tax_identifier" text,
"logo" jsonb
);
alter table "public"."companies" enable row level security;
create table "public"."contactNotes" (
"id" bigint generated by default as identity not null,
"contact_id" bigint not null,
"text" text,
"date" timestamp with time zone default now(),
"sales_id" bigint,
"status" text,
"attachments" jsonb[]
);
alter table "public"."contactNotes" enable row level security;
create table "public"."contacts" (
"id" bigint generated by default as identity not null,
"first_name" text,
"last_name" text,
"gender" text,
"title" text,
"email" text,
"phone_1_number" text,
"phone_1_type" text,
"phone_2_number" text,
"phone_2_type" text,
"background" text,
"acquisition" text,
"avatar" jsonb,
"first_seen" timestamp with time zone,
"last_seen" timestamp with time zone,
"has_newsletter" boolean,
"status" text,
"tags" bigint[],
"company_id" bigint,
"sales_id" bigint,
"linkedin_url" text
);
alter table "public"."contacts" enable row level security;
create table "public"."dealNotes" (
"id" bigint generated by default as identity not null,
"deal_id" bigint not null,
"type" text,
"text" text,
"date" timestamp with time zone default now(),
"sales_id" bigint,
"attachments" jsonb[]
);
alter table "public"."dealNotes" enable row level security;
create table "public"."deals" (
"id" bigint generated by default as identity not null,
"name" text not null,
"company_id" bigint,
"contact_ids" bigint[],
"category" text,
"stage" text not null,
"description" text,
"amount" bigint,
"created_at" timestamp with time zone not null default now(),
"updated_at" timestamp with time zone not null default now(),
"archived_at" timestamp with time zone default null,
"expected_closing_date" timestamp with time zone default null,
"sales_id" bigint,
"index" smallint
);
alter table "public"."deals" enable row level security;
create table "public"."sales" (
"id" bigint generated by default as identity not null,
"first_name" text not null,
"last_name" text not null,
"email" text not null,
"administrator" boolean not null,
"user_id" uuid not null,
"avatar" jsonb,
"disabled" boolean not null default FALSE
);
alter table "public"."sales" enable row level security;
create table "public"."tags" (
"id" bigint generated by default as identity not null,
"name" text not null,
"color" text not null
);
alter table "public"."tags" enable row level security;
create table "public"."tasks" (
"id" bigint generated by default as identity not null,
"contact_id" bigint not null,
"type" text,
"text" text,
"due_date" timestamp with time zone not null,
"done_date" timestamp with time zone
);
alter table "public"."tasks" enable row level security;
CREATE UNIQUE INDEX companies_pkey ON public.companies USING btree (id);
CREATE UNIQUE INDEX "contactNotes_pkey" ON public."contactNotes" USING btree (id);
CREATE UNIQUE INDEX contacts_pkey ON public.contacts USING btree (id);
CREATE UNIQUE INDEX "dealNotes_pkey" ON public."dealNotes" USING btree (id);
CREATE UNIQUE INDEX deals_pkey ON public.deals USING btree (id);
CREATE UNIQUE INDEX sales_pkey ON public.sales USING btree (id);
CREATE UNIQUE INDEX tags_pkey ON public.tags USING btree (id);
CREATE UNIQUE INDEX tasks_pkey ON public.tasks USING btree (id);
alter table "public"."companies" add constraint "companies_pkey" PRIMARY KEY using index "companies_pkey";
alter table "public"."contactNotes" add constraint "contactNotes_pkey" PRIMARY KEY using index "contactNotes_pkey";
alter table "public"."contacts" add constraint "contacts_pkey" PRIMARY KEY using index "contacts_pkey";
alter table "public"."dealNotes" add constraint "dealNotes_pkey" PRIMARY KEY using index "dealNotes_pkey";
alter table "public"."deals" add constraint "deals_pkey" PRIMARY KEY using index "deals_pkey";
alter table "public"."sales" add constraint "sales_pkey" PRIMARY KEY using index "sales_pkey";
alter table "public"."tags" add constraint "tags_pkey" PRIMARY KEY using index "tags_pkey";
alter table "public"."tasks" add constraint "tasks_pkey" PRIMARY KEY using index "tasks_pkey";
alter table "public"."companies" add constraint "companies_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
alter table "public"."companies" validate constraint "companies_sales_id_fkey";
alter table "public"."contactNotes" add constraint "contactNotes_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."contactNotes" validate constraint "contactNotes_contact_id_fkey";
alter table "public"."contactNotes" add constraint "contactNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."contactNotes" validate constraint "contactNotes_sales_id_fkey";
alter table "public"."contacts" add constraint "contacts_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."contacts" validate constraint "contacts_company_id_fkey";
alter table "public"."contacts" add constraint "contacts_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
alter table "public"."contacts" validate constraint "contacts_sales_id_fkey";
alter table "public"."dealNotes" add constraint "dealNotes_deal_id_fkey" FOREIGN KEY (deal_id) REFERENCES deals(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."dealNotes" validate constraint "dealNotes_deal_id_fkey";
alter table "public"."dealNotes" add constraint "dealNotes_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
alter table "public"."dealNotes" validate constraint "dealNotes_sales_id_fkey";
alter table "public"."deals" add constraint "deals_company_id_fkey" FOREIGN KEY (company_id) REFERENCES companies(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."deals" validate constraint "deals_company_id_fkey";
alter table "public"."deals" add constraint "deals_sales_id_fkey" FOREIGN KEY (sales_id) REFERENCES sales(id) not valid;
alter table "public"."deals" validate constraint "deals_sales_id_fkey";
alter table "public"."sales" add constraint "sales_user_id_fkey" FOREIGN KEY (user_id) REFERENCES auth.users(id) not valid;
alter table "public"."sales" validate constraint "sales_user_id_fkey";
alter table "public"."tasks" add constraint "tasks_contact_id_fkey" FOREIGN KEY (contact_id) REFERENCES contacts(id) ON UPDATE CASCADE ON DELETE CASCADE not valid;
alter table "public"."tasks" validate constraint "tasks_contact_id_fkey";
set check_function_bodies = off;
grant delete on table "public"."companies" to "authenticated";
grant insert on table "public"."companies" to "authenticated";
grant select on table "public"."companies" to "authenticated";
grant update on table "public"."companies" to "authenticated";
grant delete on table "public"."companies" to "service_role";
grant insert on table "public"."companies" to "service_role";
grant references on table "public"."companies" to "service_role";
grant select on table "public"."companies" to "service_role";
grant trigger on table "public"."companies" to "service_role";
grant truncate on table "public"."companies" to "service_role";
grant update on table "public"."companies" to "service_role";
grant delete on table "public"."contactNotes" to "authenticated";
grant insert on table "public"."contactNotes" to "authenticated";
grant select on table "public"."contactNotes" to "authenticated";
grant update on table "public"."contactNotes" to "authenticated";
grant delete on table "public"."contactNotes" to "service_role";
grant insert on table "public"."contactNotes" to "service_role";
grant references on table "public"."contactNotes" to "service_role";
grant select on table "public"."contactNotes" to "service_role";
grant trigger on table "public"."contactNotes" to "service_role";
grant truncate on table "public"."contactNotes" to "service_role";
grant update on table "public"."contactNotes" to "service_role";
grant delete on table "public"."contacts" to "authenticated";
grant insert on table "public"."contacts" to "authenticated";
grant select on table "public"."contacts" to "authenticated";
grant update on table "public"."contacts" to "authenticated";
grant delete on table "public"."contacts" to "service_role";
grant insert on table "public"."contacts" to "service_role";
grant references on table "public"."contacts" to "service_role";
grant select on table "public"."contacts" to "service_role";
grant trigger on table "public"."contacts" to "service_role";
grant truncate on table "public"."contacts" to "service_role";
grant update on table "public"."contacts" to "service_role";
grant delete on table "public"."dealNotes" to "authenticated";
grant insert on table "public"."dealNotes" to "authenticated";
grant select on table "public"."dealNotes" to "authenticated";
grant update on table "public"."dealNotes" to "authenticated";
grant delete on table "public"."dealNotes" to "service_role";
grant insert on table "public"."dealNotes" to "service_role";
grant references on table "public"."dealNotes" to "service_role";
grant select on table "public"."dealNotes" to "service_role";
grant trigger on table "public"."dealNotes" to "service_role";
grant truncate on table "public"."dealNotes" to "service_role";
grant update on table "public"."dealNotes" to "service_role";
grant delete on table "public"."deals" to "authenticated";
grant insert on table "public"."deals" to "authenticated";
grant select on table "public"."deals" to "authenticated";
grant update on table "public"."deals" to "authenticated";
grant delete on table "public"."deals" to "service_role";
grant insert on table "public"."deals" to "service_role";
grant references on table "public"."deals" to "service_role";
grant select on table "public"."deals" to "service_role";
grant trigger on table "public"."deals" to "service_role";
grant truncate on table "public"."deals" to "service_role";
grant update on table "public"."deals" to "service_role";
grant delete on table "public"."sales" to "authenticated";
grant insert on table "public"."sales" to "authenticated";
grant select on table "public"."sales" to "authenticated";
grant update on table "public"."sales" to "authenticated";
grant delete on table "public"."sales" to "service_role";
grant insert on table "public"."sales" to "service_role";
grant references on table "public"."sales" to "service_role";
grant select on table "public"."sales" to "service_role";
grant trigger on table "public"."sales" to "service_role";
grant truncate on table "public"."sales" to "service_role";
grant update on table "public"."sales" to "service_role";
grant delete on table "public"."tags" to "authenticated";
grant insert on table "public"."tags" to "authenticated";
grant select on table "public"."tags" to "authenticated";
grant update on table "public"."tags" to "authenticated";
grant delete on table "public"."tags" to "service_role";
grant insert on table "public"."tags" to "service_role";
grant references on table "public"."tags" to "service_role";
grant select on table "public"."tags" to "service_role";
grant trigger on table "public"."tags" to "service_role";
grant truncate on table "public"."tags" to "service_role";
grant update on table "public"."tags" to "service_role";
grant delete on table "public"."tasks" to "authenticated";
grant insert on table "public"."tasks" to "authenticated";
grant select on table "public"."tasks" to "authenticated";
grant update on table "public"."tasks" to "authenticated";
grant delete on table "public"."tasks" to "service_role";
grant insert on table "public"."tasks" to "service_role";
grant references on table "public"."tasks" to "service_role";
grant select on table "public"."tasks" to "service_role";
grant trigger on table "public"."tasks" to "service_role";
grant truncate on table "public"."tasks" to "service_role";
grant update on table "public"."tasks" to "service_role";
create policy "Enable insert for authenticated users only"
on "public"."companies"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."companies"
as permissive
for select
to authenticated
using (true);
create policy "Enable update for authenticated users only"
on "public"."companies"
as permissive
for update
to authenticated
using (true)
with check (true);
create policy "Enable insert for authenticated users only"
on "public"."contactNotes"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."contactNotes"
as permissive
for select
to authenticated
using (true);
create policy "Enable insert for authenticated users only"
on "public"."contacts"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."contacts"
as permissive
for select
to authenticated
using (true);
create policy "Enable update for authenticated users only"
on "public"."contacts"
as permissive
for update
to authenticated
using (true)
with check (true);
create policy "Enable insert for authenticated users only"
on "public"."dealNotes"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."dealNotes"
as permissive
for select
to authenticated
using (true);
create policy "Enable insert for authenticated users only"
on "public"."deals"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."deals"
as permissive
for select
to authenticated
using (true);
create policy "Enable update for authenticated users only"
on "public"."deals"
as permissive
for update
to authenticated
using (true)
with check (true);
create policy "Enable insert for authenticated users only"
on "public"."sales"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable update for authenticated users only"
on "public"."sales"
as permissive
for update
to authenticated
using (true)
with check (true);
create policy "Enable read access for authenticated users"
on "public"."sales"
as permissive
for select
to authenticated
using (true);
create policy "Enable insert for authenticated users only"
on "public"."tags"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."tags"
as permissive
for select
to authenticated
using (true);
create policy "Enable insert for authenticated users only"
on "public"."tasks"
as permissive
for insert
to authenticated
with check (true);
create policy "Enable read access for authenticated users"
on "public"."tasks"
as permissive
for select
to authenticated
using (true);
create policy "Company Delete Policy"
on "public"."companies"
as permissive
for delete
to authenticated
using (true);
create policy "Contact Notes Delete Policy"
on "public"."contactNotes"
as permissive
for delete
to authenticated
using (true);
create policy "Contact Notes Update policy"
on "public"."contactNotes"
as permissive
for update
to authenticated
using (true);
create policy "Contact Delete Policy"
on "public"."contacts"
as permissive
for delete
to authenticated
using (true);
create policy "Deal Notes Delete Policy"
on "public"."dealNotes"
as permissive
for delete
to authenticated
using (true);
create policy "Deal Notes Update Policy"
on "public"."dealNotes"
as permissive
for update
to authenticated
using (true);
create policy "Deals Delete Policy"
on "public"."deals"
as permissive
for delete
to authenticated
using (true);
create policy "Task Delete Policy"
on "public"."tasks"
as permissive
for delete
to authenticated
using (true);
create policy "Task Update Policy"
on "public"."tasks"
as permissive
for update
to authenticated
using (true);
-- Use Postgres to create a bucket.
insert into storage.buckets
(id, name, public)
values
('attachments', 'attachments', true);
CREATE POLICY "Attachments 1mt4rzk_0" ON storage.objects FOR SELECT TO authenticated USING (bucket_id = 'attachments');
CREATE POLICY "Attachments 1mt4rzk_1" ON storage.objects FOR INSERT TO authenticated WITH CHECK (bucket_id = 'attachments');
CREATE POLICY "Attachments 1mt4rzk_3" ON storage.objects FOR DELETE TO authenticated USING (bucket_id = 'attachments');
-- Use Postgres to create views for companies.
create view "public"."companies_summary"
with (security_invoker=on)
as
select
c.*,
count(distinct d.id) as nb_deals,
count(distinct co.id) as nb_contacts
from
"public"."companies" c
left join
"public"."deals" d on c.id = d.company_id
left join
"public"."contacts" co on c.id = co.company_id
group by
c.id;
-- Use Postgres to create views for contacts.
create view "public"."contacts_summary"
with (security_invoker=on)
as
select
co.*,
c.name as company_name,
count(distinct t.id) as nb_tasks
from
"public"."contacts" co
left join
"public"."tasks" t on co.id = t.contact_id
left join
"public"."companies" c on co.company_id = c.id
group by
co.id, c.name;

View File

@@ -0,0 +1,57 @@
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
declare
sales_count int;
begin
select count(id) into sales_count
from public.sales;
insert into public.sales (first_name, last_name, email, user_id, administrator)
values (
new.raw_user_meta_data ->> 'first_name',
new.raw_user_meta_data ->> 'last_name',
new.email,
new.id,
case when sales_count > 0 then FALSE else TRUE end
);
return new;
end;
$$;
create function public.handle_update_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
update public.sales
set
first_name = new.raw_user_meta_data ->> 'first_name',
last_name = new.raw_user_meta_data ->> 'last_name',
email = new.email
where user_id = new.id;
return new;
end;
$$;
create unique index "uq__sales__user_id" on public.sales (user_id);
create trigger on_auth_user_created
after insert on auth.users
for each row execute procedure public.handle_new_user();
create trigger on_auth_user_updated
after update on auth.users
for each row execute procedure public.handle_update_user();
create view init_state
with (security_invoker=off)
as
select count(id) as is_initialized
from public.sales
limit 1;

View File

@@ -0,0 +1 @@
alter table "public"."tasks" add column "sales_id" bigint;

View File

@@ -0,0 +1,20 @@
-- this will drop the `contacts_summary` view as well
ALTER TABLE "public"."contacts"
DROP COLUMN acquisition CASCADE;
-- We need to recreate the view with the new schema
create view "public"."contacts_summary"
as
select
co.*,
c.name as company_name,
count(distinct t.id) as nb_tasks
from
"public"."contacts" co
left join
"public"."tasks" t on co.id = t.contact_id
left join
"public"."companies" c on co.company_id = c.id
group by
co.id, c.name;

View File

@@ -0,0 +1,9 @@
create or replace view init_state
with (security_invoker=off)
as
select count(id) as is_initialized
from (
select id
from public.sales
limit 1
) as sub;

View File

@@ -0,0 +1,18 @@
create policy "Enable delete for authenticated users only"
on "public"."tags"
as permissive
for delete
to authenticated
using (true);
create policy "Enable update for authenticated users only"
on "public"."tags"
as permissive
for update
to authenticated
using (true);

View File

@@ -0,0 +1,7 @@
create schema if not exists "private";
set check_function_bodies = off;
drop policy "Enable insert for authenticated users only" on "public"."sales";
drop policy "Enable update for authenticated users only" on "public"."sales";

View File

@@ -0,0 +1,43 @@
alter table contacts add column email_jsonb jsonb;
update contacts set email_jsonb = ('[{"email": "' || email || '", "type": "Other"}]')::jsonb;
drop view contacts_summary;
alter table contacts drop column email;
create view contacts_summary
as
select
co.id,
co.first_name,
co.last_name,
co.gender,
co.title,
co.email_jsonb,
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
co.phone_1_number,
co.phone_1_type,
co.phone_2_number,
co.phone_2_type,
co.background,
co.avatar,
co.first_seen,
co.last_seen,
co.has_newsletter,
co.status,
co.tags,
co.company_id,
co.sales_id,
co.linkedin_url,
c.name as company_name,
count(distinct t.id) as nb_tasks
from
contacts co
left join
tasks t on co.id = t.contact_id
left join
companies c on co.company_id = c.id
group by
co.id, c.name;

View File

@@ -0,0 +1,67 @@
alter table contacts add column phone_jsonb jsonb;
update contacts set phone_jsonb =
concat(
'[',
case when phone_1_number is not null then
concat(
'{"number":"',
phone_1_number,
'","type":"',
phone_1_type,
'"}'
)
else null end,
case when phone_2_number is not null then
concat(
',',
'{"number":"',
phone_2_number,
'","type":"',
phone_2_type,
'"}'
)
else null end,
']'
)::jsonb;
drop view contacts_summary;
alter table contacts drop column phone_1_number;
alter table contacts drop column phone_1_type;
alter table contacts drop column phone_2_number;
alter table contacts drop column phone_2_type;
create view contacts_summary
as
select
co.id,
co.first_name,
co.last_name,
co.gender,
co.title,
co.email_jsonb,
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
co.phone_jsonb,
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
co.background,
co.avatar,
co.first_seen,
co.last_seen,
co.has_newsletter,
co.status,
co.tags,
co.company_id,
co.sales_id,
co.linkedin_url,
c.name as company_name,
count(distinct t.id) as nb_tasks
from
contacts co
left join
tasks t on co.id = t.contact_id
left join
companies c on co.company_id = c.id
group by
co.id, c.name;

View File

@@ -0,0 +1,153 @@
-- Create function to merge contacts server-side with transaction support
CREATE OR REPLACE FUNCTION merge_contacts(loser_id bigint, winner_id bigint)
RETURNS bigint
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
winner_contact contacts%ROWTYPE;
loser_contact contacts%ROWTYPE;
deal_record RECORD;
merged_emails jsonb;
merged_phones jsonb;
merged_tags bigint[];
winner_emails jsonb;
loser_emails jsonb;
winner_phones jsonb;
loser_phones jsonb;
email_map jsonb;
phone_map jsonb;
BEGIN
-- Fetch both contacts
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
IF winner_contact IS NULL OR loser_contact IS NULL THEN
RAISE EXCEPTION 'Contact not found';
END IF;
-- 1. Reassign tasks from loser to winner
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
-- 2. Reassign contact notes from loser to winner
UPDATE "contactNotes" SET contact_id = winner_id WHERE contact_id = loser_id;
-- 3. Update deals - replace loser with winner in contact_ids array
FOR deal_record IN
SELECT id, contact_ids
FROM deals
WHERE contact_ids @> ARRAY[loser_id]
LOOP
UPDATE deals
SET contact_ids = (
SELECT ARRAY(
SELECT DISTINCT unnest(
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
)
)
)
WHERE id = deal_record.id;
END LOOP;
-- 4. Merge contact data
-- Get email arrays
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
-- Merge emails with deduplication by email address
-- Build a map of email -> email object, then convert back to array
email_map := '{}'::jsonb;
-- Add winner emails to map
IF jsonb_array_length(winner_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
email_map := email_map || jsonb_build_object(
winner_emails->i->>'email',
winner_emails->i
);
END LOOP;
END IF;
-- Add loser emails to map (won't overwrite existing keys)
IF jsonb_array_length(loser_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
IF NOT email_map ? (loser_emails->i->>'email') THEN
email_map := email_map || jsonb_build_object(
loser_emails->i->>'email',
loser_emails->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
-- Get phone arrays
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
-- Merge phones with deduplication by number
phone_map := '{}'::jsonb;
-- Add winner phones to map
IF jsonb_array_length(winner_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
phone_map := phone_map || jsonb_build_object(
winner_phones->i->>'number',
winner_phones->i
);
END LOOP;
END IF;
-- Add loser phones to map (won't overwrite existing keys)
IF jsonb_array_length(loser_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
IF NOT phone_map ? (loser_phones->i->>'number') THEN
phone_map := phone_map || jsonb_build_object(
loser_phones->i->>'number',
loser_phones->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
-- Merge tags (remove duplicates)
merged_tags := ARRAY(
SELECT DISTINCT unnest(
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
)
);
-- 5. Update winner with merged data
UPDATE contacts SET
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
gender = COALESCE(winner_contact.gender, loser_contact.gender),
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
title = COALESCE(winner_contact.title, loser_contact.title),
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
email_jsonb = merged_emails,
phone_jsonb = merged_phones,
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
background = COALESCE(winner_contact.background, loser_contact.background),
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
tags = merged_tags
WHERE id = winner_id;
-- 6. Delete loser contact
DELETE FROM contacts WHERE id = loser_id;
RETURN winner_id;
END;
$$;

View File

@@ -0,0 +1,2 @@
-- Drop the merge_contacts function (reverting to edge function approach)
DROP FUNCTION IF EXISTS merge_contacts(bigint, bigint);

View File

@@ -0,0 +1,39 @@
CREATE OR REPLACE FUNCTION set_sales_id_default()
RETURNS TRIGGER AS $$
BEGIN
IF NEW.sales_id IS NULL THEN
SELECT id INTO NEW.sales_id FROM sales WHERE user_id = auth.uid();
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER set_task_sales_id_trigger
BEFORE INSERT ON tasks
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();
CREATE TRIGGER set_contact_sales_id_trigger
BEFORE INSERT ON contacts
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();
CREATE TRIGGER set_contact_notes_sales_id_trigger
BEFORE INSERT ON "contactNotes"
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();
CREATE TRIGGER set_company_sales_id_trigger
BEFORE INSERT ON companies
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();
CREATE TRIGGER set_deal_sales_id_trigger
BEFORE INSERT ON deals
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();
CREATE TRIGGER set_deal_notes_sales_id_trigger
BEFORE INSERT ON "dealNotes"
FOR EACH ROW
EXECUTE FUNCTION set_sales_id_default();

View File

@@ -0,0 +1,158 @@
alter table "public"."companies" rename column "stateAbbr" TO "state_abbr";
alter view "public"."companies_summary" rename column "stateAbbr" TO "state_abbr";
alter table "public"."contactNotes" rename to "contact_notes";
alter table "public"."dealNotes" rename to "deal_notes";
-- Create function to merge contacts server-side with transaction support
CREATE OR REPLACE FUNCTION merge_contacts(loser_id bigint, winner_id bigint)
RETURNS bigint
LANGUAGE plpgsql
SECURITY INVOKER
AS $$
DECLARE
winner_contact contacts%ROWTYPE;
loser_contact contacts%ROWTYPE;
deal_record RECORD;
merged_emails jsonb;
merged_phones jsonb;
merged_tags bigint[];
winner_emails jsonb;
loser_emails jsonb;
winner_phones jsonb;
loser_phones jsonb;
email_map jsonb;
phone_map jsonb;
BEGIN
-- Fetch both contacts
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
IF winner_contact IS NULL OR loser_contact IS NULL THEN
RAISE EXCEPTION 'Contact not found';
END IF;
-- 1. Reassign tasks from loser to winner
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
-- 2. Reassign contact notes from loser to winner
UPDATE "contact_notes" SET contact_id = winner_id WHERE contact_id = loser_id;
-- 3. Update deals - replace loser with winner in contact_ids array
FOR deal_record IN
SELECT id, contact_ids
FROM deals
WHERE contact_ids @> ARRAY[loser_id]
LOOP
UPDATE deals
SET contact_ids = (
SELECT ARRAY(
SELECT DISTINCT unnest(
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
)
)
)
WHERE id = deal_record.id;
END LOOP;
-- 4. Merge contact data
-- Get email arrays
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
-- Merge emails with deduplication by email address
-- Build a map of email -> email object, then convert back to array
email_map := '{}'::jsonb;
-- Add winner emails to map
IF jsonb_array_length(winner_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
email_map := email_map || jsonb_build_object(
winner_emails->i->>'email',
winner_emails->i
);
END LOOP;
END IF;
-- Add loser emails to map (won't overwrite existing keys)
IF jsonb_array_length(loser_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
IF NOT email_map ? (loser_emails->i->>'email') THEN
email_map := email_map || jsonb_build_object(
loser_emails->i->>'email',
loser_emails->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
-- Get phone arrays
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
-- Merge phones with deduplication by number
phone_map := '{}'::jsonb;
-- Add winner phones to map
IF jsonb_array_length(winner_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
phone_map := phone_map || jsonb_build_object(
winner_phones->i->>'number',
winner_phones->i
);
END LOOP;
END IF;
-- Add loser phones to map (won't overwrite existing keys)
IF jsonb_array_length(loser_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
IF NOT phone_map ? (loser_phones->i->>'number') THEN
phone_map := phone_map || jsonb_build_object(
loser_phones->i->>'number',
loser_phones->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
-- Merge tags (remove duplicates)
merged_tags := ARRAY(
SELECT DISTINCT unnest(
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
)
);
-- 5. Update winner with merged data
UPDATE contacts SET
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
gender = COALESCE(winner_contact.gender, loser_contact.gender),
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
title = COALESCE(winner_contact.title, loser_contact.title),
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
email_jsonb = merged_emails,
phone_jsonb = merged_phones,
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
background = COALESCE(winner_contact.background, loser_contact.background),
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
tags = merged_tags
WHERE id = winner_id;
-- 6. Delete loser contact
DELETE FROM contacts WHERE id = loser_id;
RETURN winner_id;
END;
$$;

View File

@@ -0,0 +1,200 @@
create extension if not exists "http" with schema "extensions";
create table "public"."favicons_excluded_domains" (
"id" bigint generated by default as identity not null,
"domain" text not null
);
alter table "public"."favicons_excluded_domains" enable row level security;
CREATE UNIQUE INDEX favicons_excluded_domains_pkey ON public.favicons_excluded_domains USING btree (id);
alter table "public"."favicons_excluded_domains" add constraint "favicons_excluded_domains_pkey" PRIMARY KEY using index "favicons_excluded_domains_pkey";
alter table "public"."tasks" alter column "due_date" drop not null;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.get_avatar_for_email(email text)
RETURNS text
LANGUAGE plpgsql
AS $function$declare email_hash text;
declare gravatar_url text;
declare gravatar_status int8;
declare email_domain text;
declare favicon_url text;
declare domain_status int8;
begin
-- Try to fetch a gravatar image
email_hash = encode(digest(email, 'sha256'), 'hex');
gravatar_url = concat('https://www.gravatar.com/avatar/', email_hash, '?d=404');
select status from http_get(gravatar_url) into gravatar_status;
if gravatar_status = 200 then
return gravatar_url;
end if;
-- Fallback to email's domain favicon if not excluded
email_domain = split_part(email, '@', 2);
return get_domain_favicon(email_domain);
exception
when others then
return 'ERROR';
end;$function$
;
CREATE OR REPLACE FUNCTION public.get_domain_favicon(domain_name text)
RETURNS text
LANGUAGE plpgsql
AS $function$declare domain_status int8;
begin
if exists (select from favicons_excluded_domains as fav where fav.domain = domain_name) then
return null;
end if;
return concat(
'https://favicon.show/',
(regexp_matches(domain_name, '^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/?\n]+)', 'i'))[1]
);
end;$function$
;
CREATE OR REPLACE FUNCTION public.handle_contact_saved()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare contact_avatar text;
declare emails_length int8;
declare item jsonb;
begin
if new.avatar is not null then
return new;
end if;
select coalesce(jsonb_array_length(new.email_jsonb), 0) into emails_length;
if emails_length = 0 then
return new;
end if;
for item in select jsonb_array_elements(new.email_jsonb)
loop
select public.get_avatar_for_email(item->>'email') into contact_avatar;
if (contact_avatar is not null) then
exit;
end if;
end loop;
if contact_avatar is null then
return new;
end if;
new.avatar = concat('{"src":"', contact_avatar, '"}');
return new;
end;$function$
;
CREATE TRIGGER contact_saved BEFORE INSERT OR UPDATE ON public.contacts FOR EACH ROW EXECUTE FUNCTION public.handle_contact_saved();
grant delete on table "public"."favicons_excluded_domains" to "authenticated";
grant insert on table "public"."favicons_excluded_domains" to "authenticated";
grant references on table "public"."favicons_excluded_domains" to "authenticated";
grant select on table "public"."favicons_excluded_domains" to "authenticated";
grant trigger on table "public"."favicons_excluded_domains" to "authenticated";
grant truncate on table "public"."favicons_excluded_domains" to "authenticated";
grant update on table "public"."favicons_excluded_domains" to "authenticated";
grant delete on table "public"."favicons_excluded_domains" to "postgres";
grant insert on table "public"."favicons_excluded_domains" to "postgres";
grant references on table "public"."favicons_excluded_domains" to "postgres";
grant select on table "public"."favicons_excluded_domains" to "postgres";
grant trigger on table "public"."favicons_excluded_domains" to "postgres";
grant truncate on table "public"."favicons_excluded_domains" to "postgres";
grant update on table "public"."favicons_excluded_domains" to "postgres";
grant delete on table "public"."favicons_excluded_domains" to "service_role";
grant insert on table "public"."favicons_excluded_domains" to "service_role";
grant references on table "public"."favicons_excluded_domains" to "service_role";
grant select on table "public"."favicons_excluded_domains" to "service_role";
grant trigger on table "public"."favicons_excluded_domains" to "service_role";
grant truncate on table "public"."favicons_excluded_domains" to "service_role";
grant update on table "public"."favicons_excluded_domains" to "service_role";
create policy "Enable access for authenticated users only"
on "public"."favicons_excluded_domains"
as permissive
for all
to authenticated
using (true)
with check (true);
CREATE OR REPLACE FUNCTION public.handle_company_saved()
RETURNS trigger
LANGUAGE plpgsql
AS $function$declare company_logo text;
begin
if new.logo is not null then
return new;
end if;
company_logo = get_domain_favicon(new.website);
if company_logo is null then
return new;
end if;
new.logo = concat('{"src":"', company_logo, '","title":"Company favicon"}');
return new;
end;$function$
;
CREATE TRIGGER company_saved BEFORE INSERT OR UPDATE ON public.companies FOR EACH ROW EXECUTE FUNCTION public.handle_company_saved();
CREATE OR REPLACE FUNCTION public.handle_contact_note_created_or_updated()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
begin
update public.contacts set last_seen = new.date where contacts.id = new.contact_id and contacts.last_seen < new.date;
return new;
end;
$function$
;
CREATE TRIGGER on_public_contact_notes_created_or_updated AFTER INSERT ON public.contact_notes FOR EACH ROW EXECUTE FUNCTION public.handle_contact_note_created_or_updated();
CREATE OR REPLACE FUNCTION get_user_id_by_email(email TEXT)
RETURNS TABLE (id uuid)
SECURITY definer
AS $$
BEGIN
RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;
END;
$$ LANGUAGE plpgsql;
revoke execute on function public.get_user_id_by_email from anon, authenticated, public;

View File

@@ -0,0 +1,51 @@
create extension if not exists "pgjwt" with schema "extensions";
alter table "public"."sales" alter column "first_name" set default 'Pending'::text;
alter table "public"."sales" alter column "last_name" set default 'Pending'::text;
set check_function_bodies = off;
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
declare
sales_count int;
begin
select count(id) into sales_count
from public.sales;
insert into public.sales (first_name, last_name, email, user_id, administrator)
values (
coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
new.email,
new.id,
case when sales_count > 0 then FALSE else TRUE end
);
return new;
end;
$function$
;
CREATE OR REPLACE FUNCTION public.handle_update_user()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
begin
update public.sales
set
first_name = coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
last_name = coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
email = new.email
where user_id = new.id;
return new;
end;
$function$
;

View File

@@ -0,0 +1,77 @@
-- Singleton table storing the app configuration as a JSONB document.
-- Admins can customize branding, company sectors, deal stages/categories,
-- note statuses, and task types via the Settings page.
-- The app merges stored values with code defaults, so missing keys are safe.
create table "public"."configuration" (
"id" integer not null default 1,
"config" jsonb not null default '{}'::jsonb,
constraint "configuration_pkey" primary key ("id"),
constraint "configuration_singleton" check ("id" = 1)
);
alter table "public"."configuration" enable row level security;
-- Helper to check admin status in RLS policies
create or replace function public.is_admin()
returns boolean language plpgsql security definer set search_path = '' as $$
begin
return exists (
select 1 from public.sales where user_id = auth.uid() and administrator = true
);
end;
$$;
-- All authenticated users can read
create policy "Enable read for authenticated" on "public"."configuration"
as permissive for select to authenticated using (true);
-- Only admins can insert/update
create policy "Enable insert for admins" on "public"."configuration"
as permissive for insert to authenticated with check (public.is_admin());
create policy "Enable update for admins" on "public"."configuration"
as permissive for update to authenticated
using (public.is_admin()) with check (public.is_admin());
-- Grants
grant select, insert, update on "public"."configuration" to "authenticated";
grant select, insert, update on "public"."configuration" to "service_role";
-- Seed empty config (code defaults apply)
insert into "public"."configuration" ("id", "config") values (1, '{}'::jsonb);
-- Migrate old label-based values to slug-based values.
--
-- Previously, companySectors, dealCategories, and taskTypes were stored as
-- plain string arrays (e.g. ["Energy", "Copywriting"]) in individual records.
-- Now they use a { value, label } format where `value` is a slug derived from
-- the label.
--
-- This converts record fields (companies.sector, deals.category, tasks.type,
-- contact_notes.status) from display labels to slugs.
CREATE OR REPLACE FUNCTION pg_temp.to_slug(label text) RETURNS text AS $$
BEGIN
RETURN TRIM(BOTH '-' FROM REGEXP_REPLACE(LOWER(label), '[^a-z0-9]+', '-', 'g'));
END;
$$ LANGUAGE plpgsql IMMUTABLE;
UPDATE companies
SET sector = pg_temp.to_slug(sector)
WHERE sector IS NOT NULL
AND sector != pg_temp.to_slug(sector);
UPDATE deals
SET category = pg_temp.to_slug(category)
WHERE category IS NOT NULL
AND category != pg_temp.to_slug(category);
UPDATE tasks
SET type = pg_temp.to_slug(type)
WHERE type IS NOT NULL
AND type != pg_temp.to_slug(type);
UPDATE contact_notes
SET status = pg_temp.to_slug(status)
WHERE status IS NOT NULL
AND status != pg_temp.to_slug(status);

View File

@@ -0,0 +1,3 @@
-- Migrating from timestamp with timezone to date only for expected_closing_date as we don't need the precision
-- And by only handling the date part in the client it caused conversion issues for users in different timezones
ALTER table "public"."deals" ALTER COLUMN "expected_closing_date" TYPE date USING ("expected_closing_date" AT TIME ZONE 'UTC')::date;

View File

@@ -0,0 +1,118 @@
-- Deletes attachment files from storage when note attachments are removed,
-- either by deleting a note or by updating a note attachments list.
CREATE EXTENSION IF NOT EXISTS pg_net;
DO $migration$
BEGIN
EXECUTE $url_sql$
-- SQL triggers cannot read Edge Function env vars directly. Resolve the
-- function URL from request context, and keep a local Docker fallback.
CREATE OR REPLACE FUNCTION public.get_note_attachments_function_url()
RETURNS text
LANGUAGE plpgsql
AS $function$
DECLARE
issuer text;
function_url text;
BEGIN
issuer := coalesce(
nullif(current_setting('request.jwt.claim.iss', true), ''),
(
coalesce(
nullif(current_setting('request.jwt.claims', true), ''),
'{}'
)::jsonb ->> 'iss'
)
);
issuer := nullif(issuer, '');
IF issuer IS NOT NULL THEN
issuer := rtrim(issuer, '/');
IF right(issuer, 8) = '/auth/v1' THEN
function_url :=
left(issuer, length(issuer) - 8) || '/functions/v1/delete_note_attachments';
IF function_url LIKE 'http://127.0.0.1:%' THEN
RETURN replace(
function_url,
'http://127.0.0.1:',
'http://host.docker.internal:'
);
END IF;
IF function_url LIKE 'http://localhost:%' THEN
RETURN replace(
function_url,
'http://localhost:',
'http://host.docker.internal:'
);
END IF;
RETURN function_url;
END IF;
END IF;
RETURN 'http://host.docker.internal:54321/functions/v1/delete_note_attachments';
END;
$function$;
$url_sql$;
EXECUTE $cleanup_sql$
CREATE OR REPLACE FUNCTION public.cleanup_note_attachments()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path TO ''
AS $function$
DECLARE
payload jsonb;
request_headers jsonb;
auth_header text;
BEGIN
request_headers := coalesce(
nullif(current_setting('request.headers', true), '')::jsonb,
'{}'::jsonb
);
auth_header := request_headers ->> 'authorization';
IF auth_header IS NULL OR auth_header = '' THEN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END IF;
payload := jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP
);
PERFORM net.http_post(
url := public.get_note_attachments_function_url(),
body := payload,
params := '{}'::jsonb,
headers := jsonb_build_object(
'Content-Type',
'application/json',
'Authorization',
auth_header
),
timeout_milliseconds := 10000
);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$function$;
$cleanup_sql$;
EXECUTE 'CREATE TRIGGER on_contact_notes_deleted_delete_note_attachments AFTER DELETE ON public.contact_notes FOR EACH ROW EXECUTE FUNCTION public.cleanup_note_attachments()';
EXECUTE 'CREATE TRIGGER on_deal_notes_deleted_delete_note_attachments AFTER DELETE ON public.deal_notes FOR EACH ROW EXECUTE FUNCTION public.cleanup_note_attachments()';
EXECUTE 'CREATE TRIGGER on_contact_notes_attachments_updated_delete_note_attachments AFTER UPDATE ON public.contact_notes FOR EACH ROW WHEN (OLD.attachments IS DISTINCT FROM NEW.attachments) EXECUTE FUNCTION public.cleanup_note_attachments()';
EXECUTE 'CREATE TRIGGER on_deal_notes_attachments_updated_delete_note_attachments AFTER UPDATE ON public.deal_notes FOR EACH ROW WHEN (OLD.attachments IS DISTINCT FROM NEW.attachments) EXECUTE FUNCTION public.cleanup_note_attachments()';
END;
$migration$;

View File

@@ -0,0 +1,18 @@
drop view if exists contacts_summary;
create view contacts_summary
as
select
co.*,
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
c.name as company_name,
count(distinct t.id) filter (where t.done_date is null) as nb_tasks
from
contacts co
left join
tasks t on co.id = t.contact_id
left join
companies c on co.company_id = c.id
group by
co.id, c.name;

View File

@@ -0,0 +1,344 @@
-- This migration allows to resolve several security warnings raised by the Supabase Advisor:
-- * Security Definer View
-- * Function Search Path Mutable
-- * Extension in Public
-- Recreate the contacts_summary view with security_invoker enabled
drop view contacts_summary;
create view contacts_summary
with (security_invoker=on)
as
select
co.*,
jsonb_path_query_array(co.email_jsonb, '$[*].email')::text as email_fts,
jsonb_path_query_array(co.phone_jsonb, '$[*].number')::text as phone_fts,
c.name as company_name,
count(distinct t.id) filter (where t.done_date is null) as nb_tasks
from
contacts co
left join
tasks t on co.id = t.contact_id
left join
companies c on co.company_id = c.id
group by
co.id, c.name;
-- Ensure all functions have search_path set to prevent search path injection attacks
-- Functions that already use qualified names can be simply altered to use public search_path
ALTER FUNCTION public.get_note_attachments_function_url()
SET search_path TO 'public';
ALTER FUNCTION public.get_user_id_by_email(text)
SET search_path TO 'public';
ALTER FUNCTION public.handle_contact_saved()
SET search_path TO 'public';
-- get_avatar_for_email: set search_path='public' and qualify extension calls
-- (http_get -> extensions.http_get, digest -> extensions.digest)
DROP FUNCTION IF EXISTS public.get_avatar_for_email(text);
CREATE FUNCTION public.get_avatar_for_email(email text)
RETURNS text
LANGUAGE plpgsql
SET search_path TO 'public'
AS $function$
declare email_hash text;
declare gravatar_url text;
declare gravatar_status int8;
declare email_domain text;
declare favicon_url text;
declare domain_status int8;
begin
-- Try to fetch a gravatar image
email_hash = encode(extensions.digest(email, 'sha256'), 'hex');
gravatar_url = concat('https://www.gravatar.com/avatar/', email_hash, '?d=404');
select status from extensions.http_get(gravatar_url) into gravatar_status;
if gravatar_status = 200 then
return gravatar_url;
end if;
-- Fallback to email's domain favicon if not excluded
email_domain = split_part(email, '@', 2);
return get_domain_favicon(email_domain);
exception
when others then
return 'ERROR';
end;
$function$;
-- get_domain_favicon: set search_path='public' (no changes to table references needed)
DROP FUNCTION IF EXISTS public.get_domain_favicon(text);
CREATE FUNCTION public.get_domain_favicon(domain_name text)
RETURNS text
LANGUAGE plpgsql
SET search_path TO 'public'
AS $function$
declare domain_status int8;
begin
if exists (select from favicons_excluded_domains as fav where fav.domain = domain_name) then
return null;
end if;
return concat(
'https://favicon.show/',
(regexp_matches(domain_name, '^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/?\n]+)', 'i'))[1]
);
end;
$function$;
-- handle_company_saved: set search_path='public' (no changes to function calls needed)
-- Drop the trigger first, then the function
DROP TRIGGER IF EXISTS company_saved ON public.companies;
DROP FUNCTION IF EXISTS public.handle_company_saved();
CREATE FUNCTION public.handle_company_saved()
RETURNS trigger
LANGUAGE plpgsql
SET search_path TO 'public'
AS $function$
declare company_logo text;
begin
if new.logo is not null then
return new;
end if;
company_logo = get_domain_favicon(new.website);
if company_logo is null then
return new;
end if;
new.logo = concat('{"src":"', company_logo, '","title":"Company favicon"}');
return new;
end;
$function$;
-- Recreate the trigger
CREATE TRIGGER company_saved BEFORE INSERT OR UPDATE ON public.companies FOR EACH ROW EXECUTE FUNCTION public.handle_company_saved();
-- set_sales_id_default: set search_path='public' (no changes to table references needed)
-- Drop all triggers that depend on this function first
DROP TRIGGER IF EXISTS set_task_sales_id_trigger ON public.tasks;
DROP TRIGGER IF EXISTS set_contact_sales_id_trigger ON public.contacts;
DROP TRIGGER IF EXISTS set_contact_notes_sales_id_trigger ON public.contact_notes;
DROP TRIGGER IF EXISTS set_company_sales_id_trigger ON public.companies;
DROP TRIGGER IF EXISTS set_deal_sales_id_trigger ON public.deals;
DROP TRIGGER IF EXISTS set_deal_notes_sales_id_trigger ON public.deal_notes;
DROP FUNCTION IF EXISTS public.set_sales_id_default();
CREATE FUNCTION public.set_sales_id_default()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path TO 'public'
AS $$
BEGIN
IF NEW.sales_id IS NULL THEN
SELECT id INTO NEW.sales_id FROM sales WHERE user_id = auth.uid();
END IF;
RETURN NEW;
END;
$$;
-- Recreate all triggers
CREATE TRIGGER set_task_sales_id_trigger
BEFORE INSERT ON public.tasks
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
CREATE TRIGGER set_contact_sales_id_trigger
BEFORE INSERT ON public.contacts
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
CREATE TRIGGER set_contact_notes_sales_id_trigger
BEFORE INSERT ON public.contact_notes
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
CREATE TRIGGER set_company_sales_id_trigger
BEFORE INSERT ON public.companies
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
CREATE TRIGGER set_deal_sales_id_trigger
BEFORE INSERT ON public.deals
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
CREATE TRIGGER set_deal_notes_sales_id_trigger
BEFORE INSERT ON public.deal_notes
FOR EACH ROW
EXECUTE FUNCTION public.set_sales_id_default();
-- merge_contacts: set search_path='public' (no changes to table references needed)
DROP FUNCTION IF EXISTS public.merge_contacts(bigint, bigint);
CREATE FUNCTION public.merge_contacts(loser_id bigint, winner_id bigint)
RETURNS bigint
LANGUAGE plpgsql
SECURITY INVOKER
SET search_path TO 'public'
AS $$
DECLARE
winner_contact contacts%ROWTYPE;
loser_contact contacts%ROWTYPE;
deal_record RECORD;
merged_emails jsonb;
merged_phones jsonb;
merged_tags bigint[];
winner_emails jsonb;
loser_emails jsonb;
winner_phones jsonb;
loser_phones jsonb;
email_map jsonb;
phone_map jsonb;
BEGIN
-- Fetch both contacts
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
IF winner_contact IS NULL OR loser_contact IS NULL THEN
RAISE EXCEPTION 'Contact not found';
END IF;
-- 1. Reassign tasks from loser to winner
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
-- 2. Reassign contact notes from loser to winner
UPDATE contact_notes SET contact_id = winner_id WHERE contact_id = loser_id;
-- 3. Update deals - replace loser with winner in contact_ids array
FOR deal_record IN
SELECT id, contact_ids
FROM deals
WHERE contact_ids @> ARRAY[loser_id]
LOOP
UPDATE deals
SET contact_ids = (
SELECT ARRAY(
SELECT DISTINCT unnest(
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
)
)
)
WHERE id = deal_record.id;
END LOOP;
-- 4. Merge contact data
-- Get email arrays
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
-- Merge emails with deduplication by email address
-- Build a map of email -> email object, then convert back to array
email_map := '{}'::jsonb;
-- Add winner emails to map
IF jsonb_array_length(winner_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
email_map := email_map || jsonb_build_object(
winner_emails->i->>'email',
winner_emails->i
);
END LOOP;
END IF;
-- Add loser emails to map (won't overwrite existing keys)
IF jsonb_array_length(loser_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
IF NOT email_map ? (loser_emails->i->>'email') THEN
email_map := email_map || jsonb_build_object(
loser_emails->i->>'email',
loser_emails->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
-- Get phone arrays
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
-- Merge phones with deduplication by number
phone_map := '{}'::jsonb;
-- Add winner phones to map
IF jsonb_array_length(winner_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
phone_map := phone_map || jsonb_build_object(
winner_phones->i->>'number',
winner_phones->i
);
END LOOP;
END IF;
-- Add loser phones to map (won't overwrite existing keys)
IF jsonb_array_length(loser_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
IF NOT phone_map ? (loser_phones->i->>'number') THEN
phone_map := phone_map || jsonb_build_object(
loser_phones->i->>'number',
loser_phones->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
-- Merge tags (remove duplicates)
merged_tags := ARRAY(
SELECT DISTINCT unnest(
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
)
);
-- 5. Update winner with merged data
UPDATE contacts SET
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
gender = COALESCE(winner_contact.gender, loser_contact.gender),
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
title = COALESCE(winner_contact.title, loser_contact.title),
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
email_jsonb = merged_emails,
phone_jsonb = merged_phones,
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
background = COALESCE(winner_contact.background, loser_contact.background),
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
tags = merged_tags
WHERE id = winner_id;
-- 6. Delete loser contact
DELETE FROM contacts WHERE id = loser_id;
RETURN winner_id;
END;
$$;
-- Move pg_net extension to extensions schema (security requirement)
-- pg_net doesn't support ALTER EXTENSION SET SCHEMA, so drop and recreate
DROP EXTENSION IF EXISTS pg_net CASCADE;
CREATE EXTENSION IF NOT EXISTS pg_net WITH SCHEMA extensions;

View File

@@ -0,0 +1,79 @@
-- Create a view that aggregates all activity events into a single, pageable list.
-- Each row represents one event (company created, contact created, note created, etc.)
-- sorted by date so PostgREST can paginate with Range headers.
create view "public"."activity_log"
with (security_invoker=on)
as
SELECT
'company.' || c.id || '.created' as id,
'company.created' as type,
c.created_at as date,
c.id as company_id,
c.sales_id,
to_json(c.*) as company,
NULL::json as contact,
NULL::json as deal,
NULL::json as contact_note,
NULL::json as deal_note
FROM companies c
UNION ALL
SELECT
'contact.' || co.id || '.created',
'contact.created',
co.first_seen,
co.company_id,
co.sales_id,
NULL::json,
to_json(co.*),
NULL::json,
NULL::json,
NULL::json
FROM contacts co
UNION ALL
SELECT
'contactNote.' || cn.id || '.created',
'contactNote.created',
cn.date,
co.company_id,
cn.sales_id,
NULL::json,
NULL::json,
NULL::json,
to_json(cn.*),
NULL::json
FROM contact_notes cn
LEFT JOIN contacts co ON co.id = cn.contact_id
UNION ALL
SELECT
'deal.' || d.id || '.created',
'deal.created',
d.created_at,
d.company_id,
d.sales_id,
NULL::json,
NULL::json,
to_json(d.*),
NULL::json,
NULL::json
FROM deals d
UNION ALL
SELECT
'dealNote.' || dn.id || '.created',
'dealNote.created',
dn.date,
d.company_id,
dn.sales_id,
NULL::json,
NULL::json,
NULL::json,
NULL::json,
to_json(dn.*)
FROM deal_notes dn
LEFT JOIN deals d ON d.id = dn.deal_id;
-- Indexes to support filtering the view by company_id efficiently
CREATE INDEX ON contacts (company_id);
CREATE INDEX ON deals (company_id);
CREATE INDEX ON contact_notes (contact_id);
CREATE INDEX ON deal_notes (deal_id);

View File

@@ -0,0 +1,86 @@
-- Enable citext extension for case-insensitive text columns
CREATE EXTENSION IF NOT EXISTS "citext" WITH SCHEMA "extensions";
-- Drop view that depends on companies.website before altering the column type
DROP VIEW IF EXISTS public.companies_summary;
-- Change companies.website to citext for case-insensitive comparisons
ALTER TABLE companies ALTER COLUMN website TYPE extensions.citext;
-- Recreate companies_summary view
CREATE OR REPLACE VIEW public.companies_summary WITH (security_invoker = on) AS
SELECT
c.id,
c.created_at,
c.name,
c.sector,
c.size,
c.linkedin_url,
c.website,
c.phone_number,
c.address,
c.zipcode,
c.city,
c.state_abbr,
c.sales_id,
c.context_links,
c.country,
c.description,
c.revenue,
c.tax_identifier,
c.logo,
count(distinct d.id) as nb_deals,
count(distinct co.id) as nb_contacts
FROM public.companies c
LEFT JOIN public.deals d ON c.id = d.company_id
LEFT JOIN public.contacts co ON c.id = co.company_id
GROUP BY c.id;
-- Change sales.email to citext for case-insensitive comparisons
ALTER TABLE sales ALTER COLUMN email TYPE extensions.citext;
-- Function to lowercase email addresses in email_jsonb
CREATE OR REPLACE FUNCTION lowercase_email_jsonb()
RETURNS TRIGGER
LANGUAGE plpgsql
SET search_path TO 'public'
AS $$
BEGIN
IF NEW.email_jsonb IS NOT NULL THEN
NEW.email_jsonb = COALESCE((
SELECT jsonb_agg(
jsonb_set(elem, '{email}', to_jsonb(LOWER(elem->>'email')))
)
FROM jsonb_array_elements(NEW.email_jsonb) AS elem
), '[]'::jsonb);
END IF;
RETURN NEW;
END;
$$;
-- Drop and recreate triggers to ensure proper ordering
-- Lowercase trigger for email_jsonb must run before the avatar trigger
-- Drop existing triggers
DROP TRIGGER IF EXISTS contact_saved ON contacts;
-- Create lowercase trigger first (so it runs before contact_saved)
CREATE TRIGGER "10_lowercase_contact_emails"
BEFORE INSERT OR UPDATE ON contacts
FOR EACH ROW
EXECUTE FUNCTION lowercase_email_jsonb();
-- Recreate contact_saved trigger (will run after lowercase_contact_emails)
CREATE TRIGGER "20_contact_saved"
BEFORE INSERT OR UPDATE ON contacts
FOR EACH ROW
EXECUTE FUNCTION handle_contact_saved();
UPDATE contacts
SET email_jsonb = COALESCE((
SELECT jsonb_agg(
jsonb_set(elem, '{email}', to_jsonb(LOWER(elem->>'email')))
)
FROM jsonb_array_elements(email_jsonb) AS elem
), '[]'::jsonb)
WHERE email_jsonb IS NOT NULL;

View File

@@ -0,0 +1,33 @@
-- The CRM's public views (activity_log, companies_summary, contacts_summary
-- and init_state) are queried through the Supabase Data API by the anon and
-- authenticated roles. `init_state` in particular is read by the anon role
-- *before* login (see src/components/atomic-crm/providers/supabase/authProvider.ts)
-- to decide whether the CRM has already been initialized.
--
-- Unlike the tables -- whose grants were captured in earlier migrations -- these
-- views never had explicit grants in a migration: they were reachable only
-- because Supabase used to grant every object in the `public` schema to the
-- anon/authenticated/service_role API roles automatically. That behavior has
-- been removed: objects are no longer exposed to the Data/GraphQL API unless
-- access is granted explicitly.
-- https://supabase.com/changelog/45329-breaking-change-tables-not-exposed-to-data-and-graphql-api-automatically
--
-- These grants are already declared in supabase/schemas/06_grants.sql (the
-- source of truth) but were never applied through a migration. Re-declare them
-- explicitly so production keeps exposing the views.
grant all on table public.activity_log to anon;
grant all on table public.activity_log to authenticated;
grant all on table public.activity_log to service_role;
grant all on table public.companies_summary to anon;
grant all on table public.companies_summary to authenticated;
grant all on table public.companies_summary to service_role;
grant all on table public.contacts_summary to anon;
grant all on table public.contacts_summary to authenticated;
grant all on table public.contacts_summary to service_role;
grant all on table public.init_state to anon;
grant all on table public.init_state to authenticated;
grant all on table public.init_state to service_role;

View File

@@ -0,0 +1,3 @@
alter table "public"."sales" add column "secondary_emails" jsonb not null default '[]'::jsonb;
alter table "public"."sales" add constraint "sales_secondary_emails_is_array" check (jsonb_typeof(secondary_emails) = 'array');

View File

@@ -0,0 +1,185 @@
--
-- Tables
-- This file declares all tables in the public schema.
--
-- Extensions
create extension if not exists "http" with schema "extensions";
create extension if not exists "citext" with schema "extensions";
-- Private schema (used by sales policies migration)
create schema if not exists "private";
create table public.companies (
id bigint generated by default as identity primary key,
created_at timestamp with time zone not null default now(),
name text not null,
sector text,
size smallint,
linkedin_url text,
website extensions.citext,
phone_number text,
address text,
zipcode text,
city text,
state_abbr text,
sales_id bigint,
context_links json,
country text,
description text,
revenue text,
tax_identifier text,
logo jsonb
);
create table public.contacts (
id bigint generated by default as identity primary key,
first_name text,
last_name text,
gender text,
title text,
background text,
avatar jsonb,
first_seen timestamp with time zone,
last_seen timestamp with time zone,
has_newsletter boolean,
status text,
tags bigint[],
company_id bigint,
sales_id bigint,
linkedin_url text,
email_jsonb jsonb,
phone_jsonb jsonb
);
create table public.contact_notes (
id bigint generated by default as identity (sequence name public."contactNotes_id_seq") not null,
contact_id bigint not null,
text text,
date timestamp with time zone default now(),
sales_id bigint,
status text,
attachments jsonb[]
);
create table public.deals (
id bigint generated by default as identity primary key,
name text not null,
company_id bigint,
contact_ids bigint[],
category text,
stage text not null,
description text,
amount bigint,
created_at timestamp with time zone not null default now(),
updated_at timestamp with time zone not null default now(),
archived_at timestamp with time zone,
expected_closing_date date,
sales_id bigint,
index smallint
);
create table public.deal_notes (
id bigint generated by default as identity (sequence name public."dealNotes_id_seq") not null,
deal_id bigint not null,
type text,
text text,
date timestamp with time zone default now(),
sales_id bigint,
attachments jsonb[]
);
create table public.sales (
id bigint generated by default as identity primary key,
first_name text not null default 'Pending'::text,
last_name text not null default 'Pending'::text,
email extensions.citext not null,
administrator boolean not null,
user_id uuid not null,
avatar jsonb,
disabled boolean not null default false,
secondary_emails jsonb not null default '[]'::jsonb,
constraint sales_secondary_emails_is_array check (jsonb_typeof(secondary_emails) = 'array')
);
create unique index uq__sales__user_id on public.sales using btree (user_id);
create table public.tags (
id bigint generated by default as identity primary key,
name text not null,
color text not null
);
create table public.tasks (
id bigint generated by default as identity primary key,
contact_id bigint not null,
type text,
text text,
due_date timestamp with time zone,
done_date timestamp with time zone,
sales_id bigint
);
create table public.configuration (
id integer not null default 1 primary key,
config jsonb not null default '{}'::jsonb,
constraint configuration_singleton check (id = 1)
);
create table public.favicons_excluded_domains (
id bigint generated by default as identity primary key,
domain text not null
);
--
-- Foreign keys
--
alter table public.companies
add constraint companies_sales_id_fkey foreign key (sales_id) references public.sales(id);
alter table public.contacts
add constraint contacts_company_id_fkey foreign key (company_id) references public.companies(id) on update cascade on delete cascade;
alter table public.contacts
add constraint contacts_sales_id_fkey foreign key (sales_id) references public.sales(id);
alter table public.contact_notes
add constraint "contactNotes_contact_id_fkey" foreign key (contact_id) references public.contacts(id) on update cascade on delete cascade;
alter table public.contact_notes
add constraint "contactNotes_sales_id_fkey" foreign key (sales_id) references public.sales(id) on update cascade on delete cascade;
alter table public.deals
add constraint deals_company_id_fkey foreign key (company_id) references public.companies(id) on update cascade on delete cascade;
alter table public.deals
add constraint deals_sales_id_fkey foreign key (sales_id) references public.sales(id);
alter table public.deal_notes
add constraint "dealNotes_deal_id_fkey" foreign key (deal_id) references public.deals(id) on update cascade on delete cascade;
alter table public.deal_notes
add constraint "dealNotes_sales_id_fkey" foreign key (sales_id) references public.sales(id);
alter table public.sales
add constraint sales_user_id_fkey foreign key (user_id) references auth.users(id);
alter table public.tasks
add constraint tasks_contact_id_fkey foreign key (contact_id) references public.contacts(id) on update cascade on delete cascade;
-- Legacy primary key constraint names (from before snake_case rename)
alter table only public.contact_notes
add constraint "contactNotes_pkey" primary key (id);
alter table only public.deal_notes
add constraint "dealNotes_pkey" primary key (id);
--
-- Indexes on foreign keys
--
create index contact_notes_contact_id_idx on public.contact_notes using btree (contact_id);
create index contacts_company_id_idx on public.contacts using btree (company_id);
create index deal_notes_deal_id_idx on public.deal_notes using btree (deal_id);
create index deals_company_id_idx on public.deals using btree (company_id);

View File

@@ -0,0 +1,455 @@
--
-- Functions
-- This file declares all PL/pgSQL functions in the public schema.
--
CREATE OR REPLACE FUNCTION "public"."cleanup_note_attachments"() RETURNS "trigger"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
DECLARE
payload jsonb;
request_headers jsonb;
auth_header text;
BEGIN
request_headers := coalesce(
nullif(current_setting('request.headers', true), '')::jsonb,
'{}'::jsonb
);
auth_header := request_headers ->> 'authorization';
IF auth_header IS NULL OR auth_header = '' THEN
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END IF;
payload := jsonb_build_object(
'old_record', OLD,
'record', NEW,
'type', TG_OP
);
PERFORM net.http_post(
url := public.get_note_attachments_function_url(),
body := payload,
params := '{}'::jsonb,
headers := jsonb_build_object(
'Content-Type',
'application/json',
'Authorization',
auth_header
),
timeout_milliseconds := 10000
);
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION "public"."get_avatar_for_email"("email" "text") RETURNS "text"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
declare email_hash text;
declare gravatar_url text;
declare gravatar_status int8;
declare email_domain text;
declare favicon_url text;
declare domain_status int8;
begin
-- Try to fetch a gravatar image
email_hash = encode(extensions.digest(email, 'sha256'), 'hex');
gravatar_url = concat('https://www.gravatar.com/avatar/', email_hash, '?d=404');
select status from extensions.http_get(gravatar_url) into gravatar_status;
if gravatar_status = 200 then
return gravatar_url;
end if;
-- Fallback to email's domain favicon if not excluded
email_domain = split_part(email, '@', 2);
return get_domain_favicon(email_domain);
exception
when others then
return 'ERROR';
end;
$$;
CREATE OR REPLACE FUNCTION "public"."get_domain_favicon"("domain_name" "text") RETURNS "text"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
declare domain_status int8;
begin
if exists (select from favicons_excluded_domains as fav where fav.domain = domain_name) then
return null;
end if;
return concat(
'https://favicon.show/',
(regexp_matches(domain_name, '^(?:https?:\/\/)?(?:[^@\/\n]+@)?(?:www\.)?([^:\/?\n]+)', 'i'))[1]
);
end;
$$;
CREATE OR REPLACE FUNCTION "public"."get_note_attachments_function_url"() RETURNS "text"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
DECLARE
issuer text;
function_url text;
BEGIN
issuer := coalesce(
nullif(current_setting('request.jwt.claim.iss', true), ''),
(
coalesce(
nullif(current_setting('request.jwt.claims', true), ''),
'{}'
)::jsonb ->> 'iss'
)
);
issuer := nullif(issuer, '');
IF issuer IS NOT NULL THEN
issuer := rtrim(issuer, '/');
IF right(issuer, 8) = '/auth/v1' THEN
function_url :=
left(issuer, length(issuer) - 8) || '/functions/v1/delete_note_attachments';
IF function_url LIKE 'http://127.0.0.1:%' THEN
RETURN replace(
function_url,
'http://127.0.0.1:',
'http://host.docker.internal:'
);
END IF;
IF function_url LIKE 'http://localhost:%' THEN
RETURN replace(
function_url,
'http://localhost:',
'http://host.docker.internal:'
);
END IF;
RETURN function_url;
END IF;
END IF;
RETURN 'http://host.docker.internal:54321/functions/v1/delete_note_attachments';
END;
$$;
CREATE OR REPLACE FUNCTION "public"."get_user_id_by_email"("email" "text") RETURNS TABLE("id" "uuid")
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO 'public'
AS $_$
BEGIN
RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;
END;
$_$;
CREATE OR REPLACE FUNCTION "public"."handle_company_saved"() RETURNS "trigger"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
declare company_logo text;
begin
if new.logo is not null then
return new;
end if;
company_logo = get_domain_favicon(new.website);
if company_logo is null then
return new;
end if;
new.logo = concat('{"src":"', company_logo, '","title":"Company favicon"}');
return new;
end;
$$;
CREATE OR REPLACE FUNCTION "public"."handle_contact_note_created_or_updated"() RETURNS "trigger"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
begin
update public.contacts set last_seen = new.date where contacts.id = new.contact_id and contacts.last_seen < new.date;
return new;
end;
$$;
CREATE OR REPLACE FUNCTION "public"."handle_contact_saved"() RETURNS "trigger"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$declare contact_avatar text;
declare emails_length int8;
declare item jsonb;
begin
if new.avatar is not null then
return new;
end if;
select coalesce(jsonb_array_length(new.email_jsonb), 0) into emails_length;
if emails_length = 0 then
return new;
end if;
for item in select jsonb_array_elements(new.email_jsonb)
loop
select public.get_avatar_for_email(item->>'email') into contact_avatar;
if (contact_avatar is not null) then
exit;
end if;
end loop;
if contact_avatar is null then
return new;
end if;
new.avatar = concat('{"src":"', contact_avatar, '"}');
return new;
end;$$;
CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
declare
sales_count int;
begin
select count(id) into sales_count
from public.sales;
insert into public.sales (first_name, last_name, email, user_id, administrator)
values (
coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
new.email,
new.id,
case when sales_count > 0 then FALSE else TRUE end
);
return new;
end;
$$;
CREATE OR REPLACE FUNCTION "public"."handle_update_user"() RETURNS "trigger"
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
begin
update public.sales
set
first_name = coalesce(new.raw_user_meta_data ->> 'first_name', new.raw_user_meta_data -> 'custom_claims' ->> 'first_name', 'Pending'),
last_name = coalesce(new.raw_user_meta_data ->> 'last_name', new.raw_user_meta_data -> 'custom_claims' ->> 'last_name', 'Pending'),
email = new.email
where user_id = new.id;
return new;
end;
$$;
CREATE OR REPLACE FUNCTION "public"."is_admin"() RETURNS boolean
LANGUAGE "plpgsql" SECURITY DEFINER
SET "search_path" TO ''
AS $$
begin
return exists (
select 1 from public.sales where user_id = auth.uid() and administrator = true
);
end;
$$;
CREATE OR REPLACE FUNCTION "public"."merge_contacts"("loser_id" bigint, "winner_id" bigint) RETURNS bigint
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
DECLARE
winner_contact contacts%ROWTYPE;
loser_contact contacts%ROWTYPE;
deal_record RECORD;
merged_emails jsonb;
merged_phones jsonb;
merged_tags bigint[];
winner_emails jsonb;
loser_emails jsonb;
winner_phones jsonb;
loser_phones jsonb;
email_map jsonb;
phone_map jsonb;
BEGIN
-- Fetch both contacts
SELECT * INTO winner_contact FROM contacts WHERE id = winner_id;
SELECT * INTO loser_contact FROM contacts WHERE id = loser_id;
IF winner_contact IS NULL OR loser_contact IS NULL THEN
RAISE EXCEPTION 'Contact not found';
END IF;
-- 1. Reassign tasks from loser to winner
UPDATE tasks SET contact_id = winner_id WHERE contact_id = loser_id;
-- 2. Reassign contact notes from loser to winner
UPDATE contact_notes SET contact_id = winner_id WHERE contact_id = loser_id;
-- 3. Update deals - replace loser with winner in contact_ids array
FOR deal_record IN
SELECT id, contact_ids
FROM deals
WHERE contact_ids @> ARRAY[loser_id]
LOOP
UPDATE deals
SET contact_ids = (
SELECT ARRAY(
SELECT DISTINCT unnest(
array_remove(deal_record.contact_ids, loser_id) || ARRAY[winner_id]
)
)
)
WHERE id = deal_record.id;
END LOOP;
-- 4. Merge contact data
-- Get email arrays
winner_emails := COALESCE(winner_contact.email_jsonb, '[]'::jsonb);
loser_emails := COALESCE(loser_contact.email_jsonb, '[]'::jsonb);
-- Merge emails with deduplication by email address
-- Build a map of email -> email object, then convert back to array
email_map := '{}'::jsonb;
-- Add winner emails to map
IF jsonb_array_length(winner_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_emails)-1 LOOP
email_map := email_map || jsonb_build_object(
winner_emails->i->>'email',
winner_emails->i
);
END LOOP;
END IF;
-- Add loser emails to map (won't overwrite existing keys)
IF jsonb_array_length(loser_emails) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_emails)-1 LOOP
IF NOT email_map ? (loser_emails->i->>'email') THEN
email_map := email_map || jsonb_build_object(
loser_emails->i->>'email',
loser_emails->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_emails := (SELECT jsonb_agg(value) FROM jsonb_each(email_map));
merged_emails := COALESCE(merged_emails, '[]'::jsonb);
-- Get phone arrays
winner_phones := COALESCE(winner_contact.phone_jsonb, '[]'::jsonb);
loser_phones := COALESCE(loser_contact.phone_jsonb, '[]'::jsonb);
-- Merge phones with deduplication by number
phone_map := '{}'::jsonb;
-- Add winner phones to map
IF jsonb_array_length(winner_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(winner_phones)-1 LOOP
phone_map := phone_map || jsonb_build_object(
winner_phones->i->>'number',
winner_phones->i
);
END LOOP;
END IF;
-- Add loser phones to map (won't overwrite existing keys)
IF jsonb_array_length(loser_phones) > 0 THEN
FOR i IN 0..jsonb_array_length(loser_phones)-1 LOOP
IF NOT phone_map ? (loser_phones->i->>'number') THEN
phone_map := phone_map || jsonb_build_object(
loser_phones->i->>'number',
loser_phones->i
);
END IF;
END LOOP;
END IF;
-- Convert map back to array
merged_phones := (SELECT jsonb_agg(value) FROM jsonb_each(phone_map));
merged_phones := COALESCE(merged_phones, '[]'::jsonb);
-- Merge tags (remove duplicates)
merged_tags := ARRAY(
SELECT DISTINCT unnest(
COALESCE(winner_contact.tags, ARRAY[]::bigint[]) ||
COALESCE(loser_contact.tags, ARRAY[]::bigint[])
)
);
-- 5. Update winner with merged data
UPDATE contacts SET
avatar = COALESCE(winner_contact.avatar, loser_contact.avatar),
gender = COALESCE(winner_contact.gender, loser_contact.gender),
first_name = COALESCE(winner_contact.first_name, loser_contact.first_name),
last_name = COALESCE(winner_contact.last_name, loser_contact.last_name),
title = COALESCE(winner_contact.title, loser_contact.title),
company_id = COALESCE(winner_contact.company_id, loser_contact.company_id),
email_jsonb = merged_emails,
phone_jsonb = merged_phones,
linkedin_url = COALESCE(winner_contact.linkedin_url, loser_contact.linkedin_url),
background = COALESCE(winner_contact.background, loser_contact.background),
has_newsletter = COALESCE(winner_contact.has_newsletter, loser_contact.has_newsletter),
first_seen = LEAST(COALESCE(winner_contact.first_seen, loser_contact.first_seen), COALESCE(loser_contact.first_seen, winner_contact.first_seen)),
last_seen = GREATEST(COALESCE(winner_contact.last_seen, loser_contact.last_seen), COALESCE(loser_contact.last_seen, winner_contact.last_seen)),
sales_id = COALESCE(winner_contact.sales_id, loser_contact.sales_id),
tags = merged_tags
WHERE id = winner_id;
-- 6. Delete loser contact
DELETE FROM contacts WHERE id = loser_id;
RETURN winner_id;
END;
$$;
CREATE OR REPLACE FUNCTION "public"."lowercase_email_jsonb"() RETURNS "trigger"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
BEGIN
IF NEW.email_jsonb IS NOT NULL THEN
NEW.email_jsonb = COALESCE((
SELECT jsonb_agg(
jsonb_set(elem, '{email}', to_jsonb(LOWER(elem->>'email')))
)
FROM jsonb_array_elements(NEW.email_jsonb) AS elem
), '[]'::jsonb);
END IF;
RETURN NEW;
END;
$$;
CREATE OR REPLACE FUNCTION "public"."set_sales_id_default"() RETURNS "trigger"
LANGUAGE "plpgsql"
SET "search_path" TO 'public'
AS $$
BEGIN
IF NEW.sales_id IS NULL THEN
SELECT id INTO NEW.sales_id FROM sales WHERE user_id = auth.uid();
END IF;
RETURN NEW;
END;
$$;

View File

@@ -0,0 +1,134 @@
--
-- Views
-- This file declares all views in the public schema.
--
create or replace view public.activity_log with (security_invoker = on) as
select
('company.' || c.id || '.created') as id,
'company.created' as type,
c.created_at as date,
c.id as company_id,
c.sales_id,
to_json(c.*) as company,
null::json as contact,
null::json as deal,
null::json as contact_note,
null::json as deal_note
from public.companies c
union all
select
('contact.' || co.id || '.created') as id,
'contact.created' as type,
co.first_seen as date,
co.company_id,
co.sales_id,
null::json as company,
to_json(co.*) as contact,
null::json as deal,
null::json as contact_note,
null::json as deal_note
from public.contacts co
union all
select
('contactNote.' || cn.id || '.created') as id,
'contactNote.created' as type,
cn.date,
co.company_id,
cn.sales_id,
null::json as company,
null::json as contact,
null::json as deal,
to_json(cn.*) as contact_note,
null::json as deal_note
from public.contact_notes cn
left join public.contacts co on co.id = cn.contact_id
union all
select
('deal.' || d.id || '.created') as id,
'deal.created' as type,
d.created_at as date,
d.company_id,
d.sales_id,
null::json as company,
null::json as contact,
to_json(d.*) as deal,
null::json as contact_note,
null::json as deal_note
from public.deals d
union all
select
('dealNote.' || dn.id || '.created') as id,
'dealNote.created' as type,
dn.date,
d.company_id,
dn.sales_id,
null::json as company,
null::json as contact,
null::json as deal,
null::json as contact_note,
to_json(dn.*) as deal_note
from public.deal_notes dn
left join public.deals d on d.id = dn.deal_id;
create or replace view public.companies_summary with (security_invoker = on) as
select
c.id,
c.created_at,
c.name,
c.sector,
c.size,
c.linkedin_url,
c.website,
c.phone_number,
c.address,
c.zipcode,
c.city,
c.state_abbr,
c.sales_id,
c.context_links,
c.country,
c.description,
c.revenue,
c.tax_identifier,
c.logo,
count(distinct d.id) as nb_deals,
count(distinct co.id) as nb_contacts
from public.companies c
left join public.deals d on c.id = d.company_id
left join public.contacts co on c.id = co.company_id
group by c.id;
create or replace view public.contacts_summary with (security_invoker = on) as
select
co.id,
co.first_name,
co.last_name,
co.gender,
co.title,
co.background,
co.avatar,
co.first_seen,
co.last_seen,
co.has_newsletter,
co.status,
co.tags,
co.company_id,
co.sales_id,
co.linkedin_url,
co.email_jsonb,
co.phone_jsonb,
(jsonb_path_query_array(co.email_jsonb, '$[*]."email"'))::text as email_fts,
(jsonb_path_query_array(co.phone_jsonb, '$[*]."number"'))::text as phone_fts,
c.name as company_name,
count(distinct t.id) filter (where t.done_date is null) as nb_tasks
from public.contacts co
left join public.tasks t on co.id = t.contact_id
left join public.companies c on co.company_id = c.id
group by co.id, c.name;
create or replace view public.init_state with (security_invoker = off) as
select count(sub.id) as is_initialized
from (
select sales.id from public.sales limit 1
) sub;

View File

@@ -0,0 +1,80 @@
--
-- Triggers
-- This file declares all triggers.
--
-- Auto-populate sales_id from current auth user on insert
create or replace trigger set_company_sales_id_trigger
before insert on public.companies
for each row execute function public.set_sales_id_default();
create or replace trigger set_contact_sales_id_trigger
before insert on public.contacts
for each row execute function public.set_sales_id_default();
create or replace trigger set_contact_notes_sales_id_trigger
before insert on public.contact_notes
for each row execute function public.set_sales_id_default();
create or replace trigger set_deal_sales_id_trigger
before insert on public.deals
for each row execute function public.set_sales_id_default();
create or replace trigger set_deal_notes_sales_id_trigger
before insert on public.deal_notes
for each row execute function public.set_sales_id_default();
create or replace trigger set_task_sales_id_trigger
before insert on public.tasks
for each row execute function public.set_sales_id_default();
-- Auto-fetch company logo from website favicon on save
create or replace trigger company_saved
before insert or update on public.companies
for each row execute function public.handle_company_saved();
-- Lowercase contact emails before insert or update (must run before contact_saved)
create or replace trigger "10_lowercase_contact_emails"
before insert or update on public.contacts
for each row execute function public.lowercase_email_jsonb();
-- Auto-fetch contact avatar from email on save (runs after lowercase_contact_emails)
create or replace trigger "20_contact_saved"
before insert or update on public.contacts
for each row execute function public.handle_contact_saved();
-- Update contact.last_seen when a contact note is created
create or replace trigger on_public_contact_notes_created_or_updated
after insert on public.contact_notes
for each row execute function public.handle_contact_note_created_or_updated();
-- Cleanup storage attachments when contact notes are updated or deleted
create or replace trigger on_contact_notes_attachments_updated_delete_note_attachments
after update on public.contact_notes
for each row
when (old.attachments is distinct from new.attachments)
execute function public.cleanup_note_attachments();
create or replace trigger on_contact_notes_deleted_delete_note_attachments
after delete on public.contact_notes
for each row execute function public.cleanup_note_attachments();
-- Cleanup storage attachments when deal notes are updated or deleted
create or replace trigger on_deal_notes_attachments_updated_delete_note_attachments
after update on public.deal_notes
for each row
when (old.attachments is distinct from new.attachments)
execute function public.cleanup_note_attachments();
create or replace trigger on_deal_notes_deleted_delete_note_attachments
after delete on public.deal_notes
for each row execute function public.cleanup_note_attachments();
-- Auth triggers: sync auth.users to public.sales
create or replace trigger on_auth_user_created
after insert on auth.users
for each row execute function public.handle_new_user();
create or replace trigger on_auth_user_updated
after update on auth.users
for each row execute function public.handle_update_user();

View File

@@ -0,0 +1,69 @@
--
-- Row Level Security
-- This file declares RLS policies for all tables.
--
-- Enable RLS on all tables
alter table public.companies enable row level security;
alter table public.contacts enable row level security;
alter table public.contact_notes enable row level security;
alter table public.deals enable row level security;
alter table public.deal_notes enable row level security;
alter table public.sales enable row level security;
alter table public.tags enable row level security;
alter table public.tasks enable row level security;
alter table public.configuration enable row level security;
alter table public.favicons_excluded_domains enable row level security;
-- Companies
create policy "Enable read access for authenticated users" on public.companies for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.companies for insert to authenticated with check (true);
create policy "Enable update for authenticated users only" on public.companies for update to authenticated using (true) with check (true);
create policy "Company Delete Policy" on public.companies for delete to authenticated using (true);
-- Contacts
create policy "Enable read access for authenticated users" on public.contacts for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.contacts for insert to authenticated with check (true);
create policy "Enable update for authenticated users only" on public.contacts for update to authenticated using (true) with check (true);
create policy "Contact Delete Policy" on public.contacts for delete to authenticated using (true);
-- Contact Notes
create policy "Enable read access for authenticated users" on public.contact_notes for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.contact_notes for insert to authenticated with check (true);
create policy "Contact Notes Update policy" on public.contact_notes for update to authenticated using (true);
create policy "Contact Notes Delete Policy" on public.contact_notes for delete to authenticated using (true);
-- Deals
create policy "Enable read access for authenticated users" on public.deals for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.deals for insert to authenticated with check (true);
create policy "Enable update for authenticated users only" on public.deals for update to authenticated using (true) with check (true);
create policy "Deals Delete Policy" on public.deals for delete to authenticated using (true);
-- Deal Notes
create policy "Enable read access for authenticated users" on public.deal_notes for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.deal_notes for insert to authenticated with check (true);
create policy "Deal Notes Update Policy" on public.deal_notes for update to authenticated using (true);
create policy "Deal Notes Delete Policy" on public.deal_notes for delete to authenticated using (true);
-- Sales
create policy "Enable read access for authenticated users" on public.sales for select to authenticated using (true);
-- Tags
create policy "Enable read access for authenticated users" on public.tags for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.tags for insert to authenticated with check (true);
create policy "Enable update for authenticated users only" on public.tags for update to authenticated using (true);
create policy "Enable delete for authenticated users only" on public.tags for delete to authenticated using (true);
-- Tasks
create policy "Enable read access for authenticated users" on public.tasks for select to authenticated using (true);
create policy "Enable insert for authenticated users only" on public.tasks for insert to authenticated with check (true);
create policy "Task Update Policy" on public.tasks for update to authenticated using (true);
create policy "Task Delete Policy" on public.tasks for delete to authenticated using (true);
-- Configuration (admin-only for writes)
create policy "Enable read for authenticated" on public.configuration for select to authenticated using (true);
create policy "Enable insert for admins" on public.configuration for insert to authenticated with check (public.is_admin());
create policy "Enable update for admins" on public.configuration for update to authenticated using (public.is_admin()) with check (public.is_admin());
-- Favicons excluded domains
create policy "Enable access for authenticated users only" on public.favicons_excluded_domains to authenticated using (true) with check (true);

View File

@@ -0,0 +1,177 @@
--
-- Grants
-- This file declares all grants and default privileges for the public schema.
--
-- Schema usage
grant usage on schema public to postgres;
grant usage on schema public to anon;
grant usage on schema public to authenticated;
grant usage on schema public to service_role;
-- Function grants
grant all on function public.cleanup_note_attachments() to anon;
grant all on function public.cleanup_note_attachments() to authenticated;
grant all on function public.cleanup_note_attachments() to service_role;
grant all on function public.get_avatar_for_email(text) to anon;
grant all on function public.get_avatar_for_email(text) to authenticated;
grant all on function public.get_avatar_for_email(text) to service_role;
grant all on function public.get_domain_favicon(text) to anon;
grant all on function public.get_domain_favicon(text) to authenticated;
grant all on function public.get_domain_favicon(text) to service_role;
grant all on function public.get_note_attachments_function_url() to anon;
grant all on function public.get_note_attachments_function_url() to authenticated;
grant all on function public.get_note_attachments_function_url() to service_role;
revoke all on function public.get_user_id_by_email(text) from public;
grant all on function public.get_user_id_by_email(text) to service_role;
grant all on function public.handle_company_saved() to anon;
grant all on function public.handle_company_saved() to authenticated;
grant all on function public.handle_company_saved() to service_role;
grant all on function public.handle_contact_note_created_or_updated() to anon;
grant all on function public.handle_contact_note_created_or_updated() to authenticated;
grant all on function public.handle_contact_note_created_or_updated() to service_role;
grant all on function public.handle_contact_saved() to anon;
grant all on function public.handle_contact_saved() to authenticated;
grant all on function public.handle_contact_saved() to service_role;
grant all on function public.handle_new_user() to anon;
grant all on function public.handle_new_user() to authenticated;
grant all on function public.handle_new_user() to service_role;
grant all on function public.handle_update_user() to anon;
grant all on function public.handle_update_user() to authenticated;
grant all on function public.handle_update_user() to service_role;
grant all on function public.is_admin() to anon;
grant all on function public.is_admin() to authenticated;
grant all on function public.is_admin() to service_role;
grant all on function public.lowercase_email_jsonb() to anon;
grant all on function public.lowercase_email_jsonb() to authenticated;
grant all on function public.lowercase_email_jsonb() to service_role;
grant all on function public.merge_contacts(bigint, bigint) to anon;
grant all on function public.merge_contacts(bigint, bigint) to authenticated;
grant all on function public.merge_contacts(bigint, bigint) to service_role;
grant all on function public.set_sales_id_default() to anon;
grant all on function public.set_sales_id_default() to authenticated;
grant all on function public.set_sales_id_default() to service_role;
-- Table grants
grant all on table public.companies to anon;
grant all on table public.companies to authenticated;
grant all on table public.companies to service_role;
grant all on table public.contacts to anon;
grant all on table public.contacts to authenticated;
grant all on table public.contacts to service_role;
grant all on table public.contact_notes to anon;
grant all on table public.contact_notes to authenticated;
grant all on table public.contact_notes to service_role;
grant all on table public.deals to anon;
grant all on table public.deals to authenticated;
grant all on table public.deals to service_role;
grant all on table public.deal_notes to anon;
grant all on table public.deal_notes to authenticated;
grant all on table public.deal_notes to service_role;
grant all on table public.sales to anon;
grant all on table public.sales to authenticated;
grant all on table public.sales to service_role;
grant all on table public.tags to anon;
grant all on table public.tags to authenticated;
grant all on table public.tags to service_role;
grant all on table public.tasks to anon;
grant all on table public.tasks to authenticated;
grant all on table public.tasks to service_role;
grant all on table public.configuration to anon;
grant all on table public.configuration to authenticated;
grant all on table public.configuration to service_role;
grant all on table public.favicons_excluded_domains to anon;
grant all on table public.favicons_excluded_domains to authenticated;
grant all on table public.favicons_excluded_domains to service_role;
-- View grants
grant all on table public.activity_log to anon;
grant all on table public.activity_log to authenticated;
grant all on table public.activity_log to service_role;
grant all on table public.companies_summary to anon;
grant all on table public.companies_summary to authenticated;
grant all on table public.companies_summary to service_role;
grant all on table public.contacts_summary to anon;
grant all on table public.contacts_summary to authenticated;
grant all on table public.contacts_summary to service_role;
grant all on table public.init_state to anon;
grant all on table public.init_state to authenticated;
grant all on table public.init_state to service_role;
-- Sequence grants
grant all on sequence public.companies_id_seq to anon;
grant all on sequence public.companies_id_seq to authenticated;
grant all on sequence public.companies_id_seq to service_role;
grant all on sequence public."contactNotes_id_seq" to anon;
grant all on sequence public."contactNotes_id_seq" to authenticated;
grant all on sequence public."contactNotes_id_seq" to service_role;
grant all on sequence public.contacts_id_seq to anon;
grant all on sequence public.contacts_id_seq to authenticated;
grant all on sequence public.contacts_id_seq to service_role;
grant all on sequence public."dealNotes_id_seq" to anon;
grant all on sequence public."dealNotes_id_seq" to authenticated;
grant all on sequence public."dealNotes_id_seq" to service_role;
grant all on sequence public.deals_id_seq to anon;
grant all on sequence public.deals_id_seq to authenticated;
grant all on sequence public.deals_id_seq to service_role;
grant all on sequence public.favicons_excluded_domains_id_seq to anon;
grant all on sequence public.favicons_excluded_domains_id_seq to authenticated;
grant all on sequence public.favicons_excluded_domains_id_seq to service_role;
grant all on sequence public.sales_id_seq to anon;
grant all on sequence public.sales_id_seq to authenticated;
grant all on sequence public.sales_id_seq to service_role;
grant all on sequence public.tags_id_seq to anon;
grant all on sequence public.tags_id_seq to authenticated;
grant all on sequence public.tags_id_seq to service_role;
grant all on sequence public.tasks_id_seq to anon;
grant all on sequence public.tasks_id_seq to authenticated;
grant all on sequence public.tasks_id_seq to service_role;
-- Default privileges
alter default privileges for role postgres in schema public grant all on sequences to postgres;
alter default privileges for role postgres in schema public grant all on sequences to anon;
alter default privileges for role postgres in schema public grant all on sequences to authenticated;
alter default privileges for role postgres in schema public grant all on sequences to service_role;
alter default privileges for role postgres in schema public grant all on functions to postgres;
alter default privileges for role postgres in schema public grant all on functions to anon;
alter default privileges for role postgres in schema public grant all on functions to authenticated;
alter default privileges for role postgres in schema public grant all on functions to service_role;
alter default privileges for role postgres in schema public grant all on tables to postgres;
alter default privileges for role postgres in schema public grant all on tables to anon;
alter default privileges for role postgres in schema public grant all on tables to authenticated;
alter default privileges for role postgres in schema public grant all on tables to service_role;

View File

@@ -0,0 +1,8 @@
--
-- Storage
-- This file declares storage bucket policies.
--
create policy "Attachments 1mt4rzk_0" on storage.objects for select to authenticated using (bucket_id = 'attachments');
create policy "Attachments 1mt4rzk_1" on storage.objects for insert to authenticated with check (bucket_id = 'attachments');
create policy "Attachments 1mt4rzk_3" on storage.objects for delete to authenticated using (bucket_id = 'attachments');

103
supabase/seed.sql Normal file
View File

@@ -0,0 +1,103 @@
INSERT INTO favicons_excluded_domains (domain) VALUES
('gmail.com'),
('yahoo.com'),
('hotmail.com'),
('aol.com'),
('hotmail.co.uk'),
('hotmail.fr'),
('msn.com'),
('yahoo.fr'),
('wanadoo.fr'),
('orange.fr'),
('comcast.net'),
('yahoo.co.uk'),
('yahoo.com.br'),
('yahoo.co.in'),
('live.com'),
('rediffmail.com'),
('free.fr'),
('gmx.de'),
('web.de'),
('yandex.ru'),
('ymail.com'),
('libero.it'),
('outlook.com'),
('uol.com.br'),
('bol.com.br'),
('mail.ru'),
('cox.net'),
('hotmail.it'),
('sbcglobal.net'),
('sfr.fr'),
('live.fr'),
('verizon.net'),
('live.co.uk'),
('googlemail.com'),
('yahoo.es'),
('ig.com.br'),
('live.nl'),
('bigpond.com'),
('terra.com.br'),
('yahoo.it'),
('neuf.fr'),
('yahoo.de'),
('alice.it'),
('rocketmail.com'),
('att.net'),
('laposte.net'),
('facebook.com'),
('bellsouth.net'),
('yahoo.in'),
('hotmail.es'),
('charter.net'),
('yahoo.ca'),
('yahoo.com.au'),
('rambler.ru'),
('hotmail.de'),
('tiscali.it'),
('shaw.ca'),
('yahoo.co.jp'),
('sky.com'),
('earthlink.net'),
('optonline.net'),
('freenet.de'),
('t-online.de'),
('aliceadsl.fr'),
('virgilio.it'),
('home.nl'),
('qq.com'),
('telenet.be'),
('me.com'),
('yahoo.com.ar'),
('tiscali.co.uk'),
('yahoo.com.mx'),
('voila.fr'),
('gmx.net'),
('mail.com'),
('planet.nl'),
('tin.it'),
('live.it'),
('ntlworld.com'),
('arcor.de'),
('yahoo.co.id'),
('frontiernet.net'),
('hetnet.nl'),
('live.com.au'),
('yahoo.com.sg'),
('zonnet.nl'),
('club-internet.fr'),
('juno.com'),
('optusnet.com.au'),
('blueyonder.co.uk'),
('bluewin.ch'),
('skynet.be'),
('sympatico.ca'),
('windstream.net'),
('mac.com'),
('centurytel.net'),
('chello.nl'),
('live.ca'),
('aim.com'),
('bigpond.net.au'),
('online.de'),
('apple.com');

View File

@@ -0,0 +1,14 @@
[
{
"kty": "EC",
"kid": "b33d4696-c075-4a37-89da-5aac9f60d31c",
"use": "sig",
"key_ops": ["sign", "verify"],
"alg": "ES256",
"ext": true,
"d": "3ED5F_XdW07rybMc0qv69nUfDUvsYa7IepLqsUD5F4Q",
"crv": "P-256",
"x": "1-VNw_pBldndnL5Xlzqdtf2rmFokpUkLUKD8jtouVWQ",
"y": "EHpnelplHQUvJZiG5METV7NTs2muYkEBr9YWcF9KqKM"
}
]

View File

@@ -0,0 +1,70 @@
<!doctype html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding: 10px 0;
}
.header h2 {
margin: 0;
color: #333333;
}
.content {
padding: 20px;
line-height: 1.6;
color: #555555;
}
.content p {
margin: 0 0 10px;
}
.button {
display: block;
width: 200px;
margin: 20px auto;
padding: 10px;
text-align: center;
background-color: #007bff;
color: #ffffff !important;
text-decoration: none;
border-radius: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Confirm your account</h2>
</div>
<div class="content">
<p>Hello {{ .Data.first_name }},</p>
<p>
You've been invited to join Atomic CRM. To confirm your account and
choose your password, please click the link below.
</p>
<p>
<a href="{{ .ConfirmationURL }}/auth-callback.html" class="button"
>Create my account</a
>
</p>
<p>See you soon!</p>
<p>The Atomic CRM team</p>
</div>
</div>
</body>
</html>

View File

@@ -0,0 +1,75 @@
<!doctype html>
<html>
<head>
<style>
body {
font-family: Arial, sans-serif;
background-color: #f4f4f4;
margin: 0;
padding: 0;
}
.container {
width: 100%;
max-width: 600px;
margin: 0 auto;
background-color: #ffffff;
padding: 20px;
border-radius: 8px;
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
padding: 10px 0;
}
.header h2 {
margin: 0;
color: #333333;
}
.content {
padding: 20px;
line-height: 1.6;
color: #555555;
}
.content p {
margin: 0 0 10px;
}
.button {
display: block;
width: 200px;
margin: 20px auto;
padding: 10px;
text-align: center;
background-color: #007bff;
color: #ffffff !important;
text-decoration: none;
border-radius: 5px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h2>Reset Your Atomic CRM Password</h2>
</div>
<div class="content">
<p>Hello,</p>
<p>
We've received a request to reset the password for the Atomic CRM
account associated with the email {{ .Email }}. No changes have been
made to your account yet.
</p>
<p>You can reset your password by clicking the button below.</p>
<p>
<a href="{{ .ConfirmationURL }}/auth-callback.html" class="button"
>Reset your password</a
>
</p>
<p>
If you did not request a new password, please contact the Atomic CRM
administrator immediately.
</p>
<p>The Atomic CRM team</p>
</div>
</div>
</body>
</html>