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
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:
168
supabase/config.e2e.toml
Normal file
168
supabase/config.e2e.toml
Normal 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
174
supabase/config.toml
Normal 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
15
supabase/functions/.env
Normal 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
|
||||
80
supabase/functions/_shared/authentication.ts
Normal file
80
supabase/functions/_shared/authentication.ts
Normal 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");
|
||||
}
|
||||
};
|
||||
20
supabase/functions/_shared/cors.ts
Normal file
20
supabase/functions/_shared/cors.ts
Normal 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);
|
||||
}
|
||||
187
supabase/functions/_shared/db.ts
Normal file
187
supabase/functions/_shared/db.ts
Normal 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(),
|
||||
},
|
||||
});
|
||||
15
supabase/functions/_shared/getUserSale.ts
Normal file
15
supabase/functions/_shared/getUserSale.ts
Normal 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;
|
||||
};
|
||||
15
supabase/functions/_shared/supabaseAdmin.ts
Normal file
15
supabase/functions/_shared/supabaseAdmin.ts
Normal 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,
|
||||
},
|
||||
},
|
||||
);
|
||||
12
supabase/functions/_shared/utils.ts
Normal file
12
supabase/functions/_shared/utils.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
158
supabase/functions/delete_note_attachments/index.ts
Normal file
158
supabase/functions/delete_note_attachments/index.ts
Normal 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" },
|
||||
});
|
||||
648
supabase/functions/mcp/index.ts
Normal file
648
supabase/functions/mcp/index.ts
Normal 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 }));
|
||||
});
|
||||
349
supabase/functions/mcp/taskListUi.ts
Normal file
349
supabase/functions/mcp/taskListUi.ts
Normal 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>
|
||||
`;
|
||||
122
supabase/functions/mcp/validateSql.test.ts
Normal file
122
supabase/functions/mcp/validateSql.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
73
supabase/functions/mcp/validateSql.ts
Normal file
73
supabase/functions/mcp/validateSql.ts
Normal 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;
|
||||
}
|
||||
194
supabase/functions/merge_contacts/index.ts
Normal file
194
supabase/functions/merge_contacts/index.ts
Normal 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");
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
647
supabase/functions/postmark/addNoteToContact.test.ts
Normal file
647
supabase/functions/postmark/addNoteToContact.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
229
supabase/functions/postmark/addNoteToContact.ts
Normal file
229
supabase/functions/postmark/addNoteToContact.ts
Normal 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);
|
||||
};
|
||||
6
supabase/functions/postmark/deno.json
Normal file
6
supabase/functions/postmark/deno.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"imports": {
|
||||
"base64-arraybuffer": "npm:base64-arraybuffer",
|
||||
"tldts": "npm:tldts"
|
||||
}
|
||||
}
|
||||
109
supabase/functions/postmark/extractAndUploadAttachments.ts
Normal file
109
supabase/functions/postmark/extractAndUploadAttachments.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
216
supabase/functions/postmark/extractMailContactData.test.ts
Normal file
216
supabase/functions/postmark/extractMailContactData.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
75
supabase/functions/postmark/extractMailContactData.ts
Normal file
75
supabase/functions/postmark/extractMailContactData.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
};
|
||||
321
supabase/functions/postmark/forwardedParser.test.ts
Normal file
321
supabase/functions/postmark/forwardedParser.test.ts
Normal 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.",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
supabase/functions/postmark/forwardedParser.ts
Normal file
83
supabase/functions/postmark/forwardedParser.ts
Normal 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;
|
||||
};
|
||||
4
supabase/functions/postmark/getExpectedAuthorization.ts
Normal file
4
supabase/functions/postmark/getExpectedAuthorization.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export const getExpectedAuthorization = (
|
||||
webhookUser: string,
|
||||
webhookPassword: string,
|
||||
) => `Basic ${btoa(`${webhookUser}:${webhookPassword}`)}`;
|
||||
6
supabase/functions/postmark/getNoteContent.ts
Normal file
6
supabase/functions/postmark/getNoteContent.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export const getNoteContent = (
|
||||
subject: string,
|
||||
strippedText: string,
|
||||
) => `${subject}
|
||||
|
||||
${strippedText}`;
|
||||
393
supabase/functions/postmark/index.ts
Normal file
393
supabase/functions/postmark/index.ts
Normal 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\"><<a href=\"mailto:original.recipient@company.com\">original.recipient@company.com</a>></span><br>Date: Fri, 1 Aug 2014 16:45:32 -04:00<br>Subject: Test for forwarding mail<br>To: Postmarkapp Support <<a href=\"mailto:support@postmarkapp.com\">support@postmarkapp.com</a>><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": []
|
||||
}'
|
||||
|
||||
*/
|
||||
104
supabase/functions/postmark/mailProvider.const.ts
Normal file
104
supabase/functions/postmark/mailProvider.const.ts
Normal 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",
|
||||
];
|
||||
38
supabase/functions/update_password/index.ts
Normal file
38
supabase/functions/update_password/index.ts
Normal 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");
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
435
supabase/functions/users/index.ts
Normal file
435
supabase/functions/users/index.ts
Normal 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");
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
74
supabase/functions/users/secondaryEmails.test.ts
Normal file
74
supabase/functions/users/secondaryEmails.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
22
supabase/functions/users/secondaryEmails.ts
Normal file
22
supabase/functions/users/secondaryEmails.ts
Normal 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));
|
||||
600
supabase/migrations/20240730075029_init_db.sql
Normal file
600
supabase/migrations/20240730075029_init_db.sql
Normal 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;
|
||||
|
||||
|
||||
57
supabase/migrations/20240730075425_init_triggers.sql
Normal file
57
supabase/migrations/20240730075425_init_triggers.sql
Normal 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;
|
||||
1
supabase/migrations/20240806124555_task_sales_id.sql
Normal file
1
supabase/migrations/20240806124555_task_sales_id.sql
Normal file
@@ -0,0 +1 @@
|
||||
alter table "public"."tasks" add column "sales_id" bigint;
|
||||
20
supabase/migrations/20240807082449_remove-aquisition.sql
Normal file
20
supabase/migrations/20240807082449_remove-aquisition.sql
Normal 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;
|
||||
@@ -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;
|
||||
18
supabase/migrations/20240813084010_tags_policy.sql
Normal file
18
supabase/migrations/20240813084010_tags_policy.sql
Normal 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);
|
||||
|
||||
|
||||
|
||||
|
||||
7
supabase/migrations/20241104153231_sales_policies.sql
Normal file
7
supabase/migrations/20241104153231_sales_policies.sql
Normal 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";
|
||||
43
supabase/migrations/20250109152531_email_jsonb.sql
Normal file
43
supabase/migrations/20250109152531_email_jsonb.sql
Normal 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;
|
||||
|
||||
67
supabase/migrations/20250113132531_phone_jsonb.sql
Normal file
67
supabase/migrations/20250113132531_phone_jsonb.sql
Normal 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;
|
||||
|
||||
153
supabase/migrations/20251204172855_merge_contacts_function.sql
Normal file
153
supabase/migrations/20251204172855_merge_contacts_function.sql
Normal 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;
|
||||
$$;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- Drop the merge_contacts function (reverting to edge function approach)
|
||||
DROP FUNCTION IF EXISTS merge_contacts(bigint, bigint);
|
||||
39
supabase/migrations/20260108160722_task_default_sales.sql
Normal file
39
supabase/migrations/20260108160722_task_default_sales.sql
Normal 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();
|
||||
158
supabase/migrations/20260115150819_snake_case_renaming.sql
Normal file
158
supabase/migrations/20260115150819_snake_case_renaming.sql
Normal 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;
|
||||
$$;
|
||||
200
supabase/migrations/20260127140209_imports.sql
Normal file
200
supabase/migrations/20260127140209_imports.sql
Normal 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;
|
||||
51
supabase/migrations/20260128165057_sso_handling.sql
Normal file
51
supabase/migrations/20260128165057_sso_handling.sql
Normal 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$
|
||||
;
|
||||
77
supabase/migrations/20260211194545_app_configuration.sql
Normal file
77
supabase/migrations/20260211194545_app_configuration.sql
Normal 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);
|
||||
@@ -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;
|
||||
118
supabase/migrations/20260304104600_note_attachments_trigger.sql
Normal file
118
supabase/migrations/20260304104600_note_attachments_trigger.sql
Normal 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$;
|
||||
18
supabase/migrations/20260307120000_nb_tasks_pending_only.sql
Normal file
18
supabase/migrations/20260307120000_nb_tasks_pending_only.sql
Normal 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;
|
||||
344
supabase/migrations/20260309112831_fix_security_warnings.sql
Normal file
344
supabase/migrations/20260309112831_fix_security_warnings.sql
Normal 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;
|
||||
|
||||
79
supabase/migrations/20260314120000_activity_log_view.sql
Normal file
79
supabase/migrations/20260314120000_activity_log_view.sql
Normal 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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
185
supabase/schemas/01_tables.sql
Normal file
185
supabase/schemas/01_tables.sql
Normal 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);
|
||||
455
supabase/schemas/02_functions.sql
Normal file
455
supabase/schemas/02_functions.sql
Normal 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;
|
||||
$$;
|
||||
134
supabase/schemas/03_views.sql
Normal file
134
supabase/schemas/03_views.sql
Normal 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;
|
||||
80
supabase/schemas/04_triggers.sql
Normal file
80
supabase/schemas/04_triggers.sql
Normal 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();
|
||||
69
supabase/schemas/05_policies.sql
Normal file
69
supabase/schemas/05_policies.sql
Normal 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);
|
||||
177
supabase/schemas/06_grants.sql
Normal file
177
supabase/schemas/06_grants.sql
Normal 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;
|
||||
8
supabase/schemas/07_storage.sql
Normal file
8
supabase/schemas/07_storage.sql
Normal 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
103
supabase/seed.sql
Normal 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');
|
||||
14
supabase/signing_keys.json
Normal file
14
supabase/signing_keys.json
Normal 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"
|
||||
}
|
||||
]
|
||||
70
supabase/templates/invite.html
Normal file
70
supabase/templates/invite.html
Normal 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>
|
||||
75
supabase/templates/recovery.html
Normal file
75
supabase/templates/recovery.html
Normal 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>
|
||||
Reference in New Issue
Block a user