Fix file sharing load speed and move error; misc updates
- Remove recursive directory size calculations (single Seafile API call per list) - Remove 'Used in this location' usage display - Fix move using v2 per-type endpoints instead of broken batch endpoint - Send entry type from frontend for correct move routing Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Executable → Regular
+1
-1
@@ -1 +1 @@
|
||||
v2.78.1
|
||||
v2.98.2
|
||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
+1
-1
@@ -1 +1 @@
|
||||
fix-optimized-search-function
|
||||
operation-ergonomics
|
||||
Executable → Regular
+1
-1
@@ -1 +1 @@
|
||||
v1.37.7
|
||||
v1.48.20
|
||||
@@ -0,0 +1,5 @@
|
||||
[functions.send-email]
|
||||
verify_jwt = false
|
||||
|
||||
[functions.create-user]
|
||||
verify_jwt = false
|
||||
@@ -0,0 +1,92 @@
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
import Stripe from 'https://esm.sh/stripe@14?target=deno';
|
||||
|
||||
const stripe = new Stripe(Deno.env.get('STRIPE_SECRET_KEY')!, { apiVersion: '2023-10-16' });
|
||||
const STRIPE_CURRENCY = 'usd';
|
||||
const STRIPE_CURRENCY_LABEL = 'USD';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const { invoice_id, invoice_ref } = await req.json();
|
||||
const invoiceRef = invoice_ref || invoice_id;
|
||||
if (!invoiceRef) return new Response(JSON.stringify({ error: 'invoice_ref required' }), { status: 400, headers: corsHeaders });
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
let error = null;
|
||||
|
||||
({ data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('id', invoiceRef)
|
||||
.maybeSingle());
|
||||
|
||||
if (!invoice) {
|
||||
({ data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('*')
|
||||
.eq('invoice_number', invoiceRef)
|
||||
.maybeSingle());
|
||||
}
|
||||
|
||||
if (error || !invoice) return new Response(JSON.stringify({ error: 'Invoice not found' }), { status: 404, headers: corsHeaders });
|
||||
if (invoice.status === 'paid') return new Response(JSON.stringify({ error: 'Invoice already paid' }), { status: 400, headers: corsHeaders });
|
||||
|
||||
const { data: company } = invoice.company_id
|
||||
? await supabase
|
||||
.from('companies')
|
||||
.select('name, email')
|
||||
.eq('id', invoice.company_id)
|
||||
.maybeSingle()
|
||||
: { data: null };
|
||||
|
||||
const origin = 'https://portal.fourgebranding.com';
|
||||
const billToLabel = invoice.bill_to || company?.name;
|
||||
|
||||
const session = await stripe.checkout.sessions.create({
|
||||
// No payment_method_types — Stripe uses whatever is enabled in the dashboard
|
||||
// (cards, ACH, etc.) without needing to hardcode them here.
|
||||
mode: 'payment',
|
||||
customer_email: company?.email || undefined,
|
||||
line_items: [{
|
||||
price_data: {
|
||||
currency: STRIPE_CURRENCY,
|
||||
product_data: {
|
||||
name: `Invoice ${invoice.invoice_number} (${STRIPE_CURRENCY_LABEL})`,
|
||||
description: billToLabel
|
||||
? `Fourge Branding — ${billToLabel} (${STRIPE_CURRENCY_LABEL})`
|
||||
: `Fourge Branding invoice charged in ${STRIPE_CURRENCY_LABEL}`,
|
||||
},
|
||||
unit_amount: Math.round(Number(invoice.total) * 100),
|
||||
},
|
||||
quantity: 1,
|
||||
}],
|
||||
// Stamp invoice_id on both the session and the payment intent so the
|
||||
// webhook can match it whether the payment settles instantly (card) or
|
||||
// asynchronously (ACH — payment_intent.succeeded fires days later).
|
||||
metadata: { invoice_id: invoice.id, currency: STRIPE_CURRENCY },
|
||||
payment_intent_data: {
|
||||
metadata: { invoice_id: invoice.id },
|
||||
},
|
||||
success_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?success=1`,
|
||||
cancel_url: `${origin}/pay/${encodeURIComponent(invoice.invoice_number)}?cancelled=1`,
|
||||
});
|
||||
|
||||
return new Response(JSON.stringify({ url: session.url }), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return new Response(JSON.stringify({ error: err.message }), { status: 500, headers: corsHeaders });
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
// deno-lint-ignore-file
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
@@ -7,17 +6,18 @@ const corsHeaders = {
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const ok = (body: Record<string, unknown>) => new Response(JSON.stringify(body), {
|
||||
status: 200,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
const json = (body: Record<string, unknown>, status: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
serve(async (req) => {
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization') ?? ''
|
||||
if (!authHeader) return ok({ error: 'No authorization header' })
|
||||
if (!authHeader) return json({ error: 'No authorization header' }, 401)
|
||||
|
||||
const callerClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
@@ -26,16 +26,19 @@ serve(async (req) => {
|
||||
)
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser()
|
||||
if (userError || !userData?.user) return ok({ error: `Auth failed: ${userError?.message ?? 'no user'}` })
|
||||
if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles').select('role').eq('id', userData.user.id).single()
|
||||
if (profileError) return ok({ error: `Profile error: ${profileError.message}` })
|
||||
if (profile?.role !== 'team') return ok({ error: 'Forbidden: team only' })
|
||||
if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
|
||||
if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
|
||||
|
||||
const body = await req.json()
|
||||
const { name, email, password, company_id } = body
|
||||
if (!name || !email || !password) return ok({ error: 'name, email and password are required' })
|
||||
const { name, email, password, company_id, role: roleParam } = body
|
||||
if (!name || !email || !password) return json({ error: 'name, email and password are required' }, 400)
|
||||
|
||||
const validRoles = ['team', 'client', 'external']
|
||||
const role = validRoles.includes(roleParam) ? roleParam : 'client'
|
||||
|
||||
const admin = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
@@ -46,20 +49,47 @@ serve(async (req) => {
|
||||
email,
|
||||
password,
|
||||
email_confirm: true,
|
||||
user_metadata: { name, role: 'client' },
|
||||
user_metadata: { name, role },
|
||||
})
|
||||
|
||||
if (createError) return ok({ error: `Create user failed: ${createError.message}` })
|
||||
if (createError) return json({ error: `Create user failed: ${createError.message}` }, 500)
|
||||
|
||||
if (role === 'client' && company_id && created?.user) {
|
||||
const { error: profileUpsertError } = await admin
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: created.user.id,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
company_id,
|
||||
}, { onConflict: 'id' })
|
||||
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
|
||||
|
||||
if (company_id && created?.user) {
|
||||
const { error: assignError } = await admin
|
||||
.from('profiles').update({ company_id }).eq('id', created.user.id)
|
||||
if (assignError) return ok({ error: `User created but company assign failed: ${assignError.message}` })
|
||||
if (assignError) return json({ error: `User created but company assign failed: ${assignError.message}` }, 500)
|
||||
|
||||
const { error: membershipError } = await admin
|
||||
.from('company_members')
|
||||
.upsert({ company_id, profile_id: created.user.id }, { onConflict: 'company_id,profile_id' })
|
||||
if (membershipError) return json({ error: `User created but company membership failed: ${membershipError.message}` }, 500)
|
||||
} else if (role === 'external' && created?.user) {
|
||||
const { error: profileUpsertError } = await admin
|
||||
.from('profiles')
|
||||
.upsert({
|
||||
id: created.user.id,
|
||||
name,
|
||||
email,
|
||||
role,
|
||||
company_id: null,
|
||||
}, { onConflict: 'id' })
|
||||
if (profileUpsertError) return json({ error: `User created but profile setup failed: ${profileUpsertError.message}` }, 500)
|
||||
}
|
||||
|
||||
return ok({ success: true })
|
||||
return json({ success: true }, 200)
|
||||
|
||||
} catch (err) {
|
||||
return ok({ error: `Unexpected: ${String(err)}` })
|
||||
return json({ error: `Unexpected: ${String(err)}` }, 500)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
// deno-lint-ignore-file
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const json = (body: Record<string, unknown>, status: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization') ?? ''
|
||||
if (!authHeader) return json({ error: 'No authorization header' }, 401)
|
||||
|
||||
const callerClient = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_ANON_KEY') ?? '',
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
)
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser()
|
||||
if (userError || !userData?.user) return json({ error: `Auth failed: ${userError?.message ?? 'no user'}` }, 401)
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles').select('role').eq('id', userData.user.id).single()
|
||||
if (profileError) return json({ error: `Profile error: ${profileError.message}` }, 500)
|
||||
if (profile?.role !== 'team') return json({ error: 'Forbidden: team only' }, 403)
|
||||
|
||||
const body = await req.json()
|
||||
const { user_id } = body
|
||||
if (!user_id) return json({ error: 'user_id is required' }, 400)
|
||||
|
||||
const admin = createClient(
|
||||
Deno.env.get('SUPABASE_URL') ?? '',
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') ?? ''
|
||||
)
|
||||
|
||||
const { error: deleteError } = await admin.auth.admin.deleteUser(user_id)
|
||||
if (deleteError) return json({ error: `Delete failed: ${deleteError.message}` }, 500)
|
||||
|
||||
return json({ success: true }, 200)
|
||||
|
||||
} catch (err) {
|
||||
return json({ error: `Unexpected: ${String(err)}` }, 500)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,99 @@
|
||||
// deno-lint-ignore-file
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2'
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
}
|
||||
|
||||
const json = (body: Record<string, unknown>, status: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
})
|
||||
|
||||
function encodeBase64(bytes: Uint8Array) {
|
||||
return btoa(String.fromCharCode(...bytes))
|
||||
}
|
||||
|
||||
function decodeBase64(value: string) {
|
||||
return Uint8Array.from(atob(value), char => char.charCodeAt(0))
|
||||
}
|
||||
|
||||
async function requireTeam(authHeader: string) {
|
||||
const supabaseUrl = Deno.env.get('SUPABASE_URL') ?? ''
|
||||
const supabaseKey = Deno.env.get('SUPABASE_ANON_KEY') || Deno.env.get('SUPABASE_SERVICE_ROLE_KEY') || ''
|
||||
|
||||
const callerClient = createClient(
|
||||
supabaseUrl,
|
||||
supabaseKey,
|
||||
{ global: { headers: { Authorization: authHeader } } }
|
||||
)
|
||||
|
||||
const { data: userData, error: userError } = await callerClient.auth.getUser()
|
||||
if (userError || !userData?.user) return { ok: false, error: `Auth failed: ${userError?.message ?? 'no user'}`, status: 401 }
|
||||
|
||||
const { data: profile, error: profileError } = await callerClient
|
||||
.from('profiles').select('role').eq('id', userData.user.id).single()
|
||||
|
||||
if (profileError) return { ok: false, error: `Profile error: ${profileError.message}`, status: 500 }
|
||||
if (profile?.role !== 'team') return { ok: false, error: 'Forbidden: team only', status: 403 }
|
||||
|
||||
return { ok: true }
|
||||
}
|
||||
|
||||
async function getKey() {
|
||||
const secret = Deno.env.get('PASSWORD_VAULT_KEY') ?? ''
|
||||
if (!secret) throw new Error('PASSWORD_VAULT_KEY is not configured.')
|
||||
|
||||
const rawKey = decodeBase64(secret)
|
||||
return crypto.subtle.importKey('raw', rawKey, 'AES-GCM', false, ['encrypt', 'decrypt'])
|
||||
}
|
||||
|
||||
Deno.serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders })
|
||||
|
||||
try {
|
||||
const authHeader = req.headers.get('Authorization') ?? ''
|
||||
if (!authHeader) return json({ error: 'No authorization header' }, 401)
|
||||
|
||||
const auth = await requireTeam(authHeader)
|
||||
if (!auth.ok) return json({ error: auth.error as string }, auth.status as number)
|
||||
|
||||
const body = await req.json()
|
||||
const action = body?.action
|
||||
const key = await getKey()
|
||||
|
||||
if (action === 'encrypt') {
|
||||
const plaintext = String(body?.plaintext ?? '')
|
||||
if (!plaintext) return json({ error: 'plaintext required' }, 400)
|
||||
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12))
|
||||
const encoded = new TextEncoder().encode(plaintext)
|
||||
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoded)
|
||||
|
||||
return json({
|
||||
ciphertext: encodeBase64(new Uint8Array(ciphertext)),
|
||||
iv: encodeBase64(iv),
|
||||
}, 200)
|
||||
}
|
||||
|
||||
if (action === 'decrypt') {
|
||||
const ciphertext = String(body?.ciphertext ?? '')
|
||||
const ivValue = String(body?.iv ?? '')
|
||||
if (!ciphertext || !ivValue) return json({ error: 'ciphertext and iv required' }, 400)
|
||||
|
||||
const decrypted = await crypto.subtle.decrypt(
|
||||
{ name: 'AES-GCM', iv: decodeBase64(ivValue) },
|
||||
key,
|
||||
decodeBase64(ciphertext),
|
||||
)
|
||||
|
||||
return json({ plaintext: new TextDecoder().decode(decrypted) }, 200)
|
||||
}
|
||||
|
||||
return json({ error: 'Invalid action' }, 400)
|
||||
} catch (err) {
|
||||
return json({ error: `Unexpected: ${String(err)}` }, 500)
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,70 @@
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') return new Response('ok', { headers: corsHeaders });
|
||||
|
||||
try {
|
||||
const { invoice_id, invoice_ref } = await req.json();
|
||||
const invoiceRef = invoice_ref || invoice_id;
|
||||
|
||||
if (!invoiceRef) {
|
||||
return new Response(JSON.stringify({ error: 'invoice_ref required' }), {
|
||||
status: 400,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
let invoice = null;
|
||||
let error = null;
|
||||
|
||||
({ data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
|
||||
.eq('id', invoiceRef)
|
||||
.maybeSingle());
|
||||
|
||||
if (!invoice) {
|
||||
({ data: invoice, error } = await supabase
|
||||
.from('invoices')
|
||||
.select('id, invoice_number, invoice_date, due_date, total, status, bill_to, company_id')
|
||||
.eq('invoice_number', invoiceRef)
|
||||
.maybeSingle());
|
||||
}
|
||||
|
||||
if (error || !invoice) {
|
||||
return new Response(JSON.stringify({ error: 'Invoice not found' }), {
|
||||
status: 404,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
|
||||
const { data: company } = invoice.company_id
|
||||
? await supabase
|
||||
.from('companies')
|
||||
.select('name, email')
|
||||
.eq('id', invoice.company_id)
|
||||
.maybeSingle()
|
||||
: { data: null };
|
||||
|
||||
return new Response(JSON.stringify({ invoice: { ...invoice, companies: company || null } }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
status: 500,
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1,102 +1,445 @@
|
||||
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts';
|
||||
import { createClient } from 'https://esm.sh/@supabase/supabase-js@2';
|
||||
|
||||
const RESEND_API_KEY = Deno.env.get('RESEND_API_KEY');
|
||||
const SUPABASE_URL = Deno.env.get('SUPABASE_URL');
|
||||
const SUPABASE_SERVICE_ROLE_KEY = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY');
|
||||
const FROM = 'Fourge Branding <hello@fourgebranding.com>';
|
||||
const TEAM_EMAILS = [
|
||||
'krao@fourgebranding.com',
|
||||
'smarsell@fourgebranding.com',
|
||||
'swebb@fourgebranding.com',
|
||||
'twebb@fourgebranding.com',
|
||||
];
|
||||
|
||||
const ALLOWED_TYPES = ['new_request', 'sent_to_client', 'revision_submitted', 'client_approved', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'] as const;
|
||||
type EmailType = typeof ALLOWED_TYPES[number];
|
||||
|
||||
// Types that only team members may trigger
|
||||
const TEAM_ONLY_TYPES: EmailType[] = ['sent_to_client', 'invoice_sent', 'receipt_sent', 'subcontractor_po_sent'];
|
||||
|
||||
const corsHeaders = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type',
|
||||
};
|
||||
|
||||
/** Escape a value for safe insertion into HTML. */
|
||||
const esc = (s: unknown): string => {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
};
|
||||
|
||||
/** Escape and convert newlines to <br> for multi-line text fields. */
|
||||
const escMultiline = (s: unknown): string =>
|
||||
esc(s).replace(/\n/g, '<br>');
|
||||
|
||||
/** Validate a string field: must be a non-empty string within max length. */
|
||||
const requireStr = (val: unknown, max = 500): string => {
|
||||
if (typeof val !== 'string' || !val.trim()) throw new Error('Missing required field');
|
||||
if (val.length > max) throw new Error('Field exceeds maximum length');
|
||||
return val.trim();
|
||||
};
|
||||
|
||||
/** Validate an optional string field. Returns '' if absent. */
|
||||
const optStr = (val: unknown, max = 500): string => {
|
||||
if (val == null || val === '') return '';
|
||||
if (typeof val !== 'string') throw new Error('Invalid field type');
|
||||
if (val.length > max) throw new Error('Field exceeds maximum length');
|
||||
return val.trim();
|
||||
};
|
||||
|
||||
/** Basic UUID format check. */
|
||||
const isUuid = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(v);
|
||||
|
||||
/** Basic email format check. */
|
||||
const isEmail = (v: unknown): v is string =>
|
||||
typeof v === 'string' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(v) && v.length <= 254;
|
||||
|
||||
const parseJsonSafe = async (res: Response) => {
|
||||
const text = await res.text();
|
||||
if (!text) return null;
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return { raw: text };
|
||||
}
|
||||
};
|
||||
|
||||
const isBase64 = (value: unknown): value is string =>
|
||||
typeof value === 'string' && value.length <= 10_000_000 && /^[A-Za-z0-9+/=]+$/.test(value);
|
||||
|
||||
const safeFilename = (value: unknown): string => {
|
||||
const filename = requireStr(value, 180);
|
||||
if (!/^[a-zA-Z0-9._ -]+\.pdf$/i.test(filename)) throw new Error('Invalid attachment filename');
|
||||
return filename;
|
||||
};
|
||||
|
||||
serve(async (req) => {
|
||||
if (req.method === 'OPTIONS') {
|
||||
return new Response('ok', { headers: corsHeaders });
|
||||
}
|
||||
|
||||
try {
|
||||
const { type, to, data } = await req.json();
|
||||
const respond = (body: unknown, status: number) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status,
|
||||
});
|
||||
|
||||
try {
|
||||
if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY) {
|
||||
return respond({ error: 'Missing Supabase environment variables' }, 500);
|
||||
}
|
||||
if (!RESEND_API_KEY) {
|
||||
return respond({ error: 'Missing RESEND_API_KEY' }, 500);
|
||||
}
|
||||
|
||||
// ── 1. Authentication ────────────────────────────────────────────────────
|
||||
const authHeader = req.headers.get('Authorization') ?? '';
|
||||
const accessToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
||||
if (!accessToken) {
|
||||
return respond({ error: 'Unauthorized' }, 401);
|
||||
}
|
||||
|
||||
// Use service role client to validate the token — works with ES256 JWTs
|
||||
const adminClient = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY);
|
||||
|
||||
const { data: userData, error: authError } = await adminClient.auth.getUser(accessToken);
|
||||
if (authError || !userData?.user) {
|
||||
return respond({ error: `Auth failed: ${authError?.message ?? 'no user'}` }, 401);
|
||||
}
|
||||
|
||||
// ── 2. Role lookup ───────────────────────────────────────────────────────
|
||||
const { data: profile } = await adminClient
|
||||
.from('profiles')
|
||||
.select('role')
|
||||
.eq('id', userData.user.id)
|
||||
.single();
|
||||
|
||||
const role: string = profile?.role ?? '';
|
||||
if (!['team', 'client', 'external'].includes(role)) {
|
||||
return respond({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
// ── 3. Parse and validate body ───────────────────────────────────────────
|
||||
const body = await req.json();
|
||||
const { type, to, data, attachments } = body;
|
||||
|
||||
if (!ALLOWED_TYPES.includes(type)) {
|
||||
return respond({ error: 'Invalid email type' }, 400);
|
||||
}
|
||||
|
||||
if (TEAM_ONLY_TYPES.includes(type) && role !== 'team') {
|
||||
return respond({ error: 'Forbidden' }, 403);
|
||||
}
|
||||
|
||||
const safeAttachments = Array.isArray(attachments)
|
||||
? attachments.slice(0, 3).map((attachment) => {
|
||||
const filename = safeFilename(attachment?.filename);
|
||||
if (!isBase64(attachment?.content)) throw new Error('Invalid attachment content');
|
||||
return { filename, content: attachment.content };
|
||||
})
|
||||
: [];
|
||||
|
||||
// ── 4. Build email per type (with validated + escaped fields) ────────────
|
||||
let subject = '';
|
||||
let html = '';
|
||||
|
||||
if (type === 'new_request') {
|
||||
subject = `New Request: ${data.serviceType} — ${data.clientName}`;
|
||||
const serviceType = requireStr(data?.serviceType);
|
||||
const clientName = requireStr(data?.clientName);
|
||||
const clientEmail = requireStr(data?.clientEmail);
|
||||
const projectName = requireStr(data?.projectName);
|
||||
const description = requireStr(data?.description, 10000);
|
||||
const company = optStr(data?.company);
|
||||
const deadline = optStr(data?.deadline);
|
||||
const taskId = data?.taskId;
|
||||
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
||||
|
||||
subject = `New Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>New Request Received</h2>
|
||||
<p><strong>From:</strong> ${data.clientName} (${data.clientEmail})</p>
|
||||
<p><strong>Company:</strong> ${data.company || '—'}</p>
|
||||
<p><strong>Service:</strong> ${data.serviceType}</p>
|
||||
<p><strong>Project:</strong> ${data.projectName}</p>
|
||||
${data.deadline ? `<p><strong>Deadline:</strong> ${data.deadline}</p>` : ''}
|
||||
<p><strong>From:</strong> ${esc(clientName)} (${esc(clientEmail)})</p>
|
||||
<p><strong>Company:</strong> ${esc(company) || '—'}</p>
|
||||
<p><strong>Service:</strong> ${esc(serviceType)}</p>
|
||||
<p><strong>Project:</strong> ${esc(projectName)}</p>
|
||||
${deadline ? `<p><strong>Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Description:</strong></p>
|
||||
<p>${data.description}</p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'sent_to_client') {
|
||||
subject = `Your ${data.serviceType} is ready for review!`;
|
||||
const serviceType = requireStr(data?.serviceType);
|
||||
const clientFirstName = requireStr(data?.clientFirstName);
|
||||
const projectName = requireStr(data?.projectName);
|
||||
const message = optStr(data?.message, 5000);
|
||||
const taskId = data?.taskId;
|
||||
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
||||
|
||||
subject = `Your ${esc(serviceType)} is ready for review!`;
|
||||
html = `
|
||||
<h2>Hi ${data.clientFirstName},</h2>
|
||||
<p>Your <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong> is ready for review.</p>
|
||||
${data.message ? `<p>${data.message}</p>` : ''}
|
||||
<h2>Hi ${esc(clientFirstName)},</h2>
|
||||
<p>Your <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong> is ready for review.</p>
|
||||
${message ? `<p>${escMultiline(message)}</p>` : ''}
|
||||
<p>Please log in to the portal to review and approve or request changes.</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/my-requests/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
|
||||
<a href="https://portal.fourgebranding.com/my-requests/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">Review Your Work</a>
|
||||
<br /><br />
|
||||
<p style="color:#666;font-size:13px;">— The Fourge Branding Team</p>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'revision_submitted') {
|
||||
subject = `Revision Request: ${data.serviceType} — ${data.clientName}`;
|
||||
const serviceType = requireStr(data?.serviceType);
|
||||
const clientName = requireStr(data?.clientName);
|
||||
const projectName = requireStr(data?.projectName);
|
||||
const version = requireStr(data?.version);
|
||||
const description = requireStr(data?.description, 10000);
|
||||
const deadline = optStr(data?.deadline);
|
||||
const taskId = data?.taskId;
|
||||
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
||||
|
||||
subject = `Revision Request: ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>Revision Requested</h2>
|
||||
<p><strong>From:</strong> ${data.clientName}</p>
|
||||
<p><strong>Job:</strong> ${data.serviceType} — ${data.projectName}</p>
|
||||
<p><strong>New Version:</strong> ${data.version}</p>
|
||||
${data.deadline ? `<p><strong>New Deadline:</strong> ${data.deadline}</p>` : ''}
|
||||
<p><strong>From:</strong> ${esc(clientName)}</p>
|
||||
<p><strong>Job:</strong> ${esc(serviceType)} — ${esc(projectName)}</p>
|
||||
<p><strong>New Version:</strong> ${esc(version)}</p>
|
||||
${deadline ? `<p><strong>New Deadline:</strong> ${esc(deadline)}</p>` : ''}
|
||||
<hr />
|
||||
<p><strong>Requested changes:</strong></p>
|
||||
<p>${data.description}</p>
|
||||
<p>${escMultiline(description)}</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'client_approved') {
|
||||
subject = `Approved! ${data.serviceType} — ${data.clientName}`;
|
||||
const serviceType = requireStr(data?.serviceType);
|
||||
const clientName = requireStr(data?.clientName);
|
||||
const projectName = requireStr(data?.projectName);
|
||||
const taskId = data?.taskId;
|
||||
if (!isUuid(taskId)) throw new Error('Invalid taskId');
|
||||
|
||||
subject = `Approved! ${esc(serviceType)} — ${esc(clientName)}`;
|
||||
html = `
|
||||
<h2>Job Approved ✓</h2>
|
||||
<p><strong>${data.clientName}</strong> has approved <strong>${data.serviceType}</strong> for <strong>${data.projectName}</strong>.</p>
|
||||
<p><strong>${esc(clientName)}</strong> has approved <strong>${esc(serviceType)}</strong> for <strong>${esc(projectName)}</strong>.</p>
|
||||
<p>This job is now complete.</p>
|
||||
<br />
|
||||
<a href="https://portal.fourgebranding.com/tasks/${data.taskId}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
<a href="https://portal.fourgebranding.com/tasks/${esc(taskId)}" style="background:#F5A523;color:#1a1a1a;padding:10px 20px;border-radius:6px;text-decoration:none;font-weight:600;">View Job</a>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'invoice_sent') {
|
||||
const invoiceNumber = requireStr(data?.invoiceNumber);
|
||||
const billTo = requireStr(data?.billTo);
|
||||
const total = requireStr(data?.total);
|
||||
const dueDate = requireStr(data?.dueDate);
|
||||
const payUrl = requireStr(data?.payUrl);
|
||||
const notes = optStr(data?.notes, 2000);
|
||||
|
||||
subject = `Invoice ${esc(invoiceNumber)} from Fourge Branding — ${esc(total)} due ${esc(dueDate)}`;
|
||||
html = `
|
||||
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">Please find your invoice from Fourge Branding below.</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Due</td>
|
||||
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(total)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Due Date</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
${notes ? `<p style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">${escMultiline(notes)}</p>` : ''}
|
||||
|
||||
<a href="${esc(payUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Pay Now — ${esc(total)}</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Secured by Stripe · Charged in USD<br/>
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'receipt_sent') {
|
||||
const invoiceNumber = requireStr(data?.invoiceNumber);
|
||||
const billTo = requireStr(data?.billTo);
|
||||
const total = requireStr(data?.total);
|
||||
const paidDate = requireStr(data?.paidDate);
|
||||
|
||||
subject = `Payment Receipt — ${esc(invoiceNumber)} — Thank you!`;
|
||||
html = `
|
||||
<div style="font-family:sans-serif;max-width:520px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<div style="text-align:center;margin-bottom:20px;">
|
||||
<div style="display:inline-block;background:#16a34a;color:#fff;font-weight:700;font-size:13px;padding:6px 16px;border-radius:20px;letter-spacing:0.5px;">✓ PAYMENT RECEIVED</div>
|
||||
</div>
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(billTo)},</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">Thank you — your payment has been received. Here's your receipt.</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Invoice #</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(invoiceNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount Paid</td>
|
||||
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#16a34a;text-align:right;">${esc(total)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Payment Date</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(paidDate)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
else if (type === 'subcontractor_po_sent') {
|
||||
const poNumber = requireStr(data?.poNumber);
|
||||
const subcontractorName = requireStr(data?.subcontractorName);
|
||||
const projectName = requireStr(data?.projectName);
|
||||
const companyName = requireStr(data?.companyName);
|
||||
const amount = requireStr(data?.amount);
|
||||
const dueDate = requireStr(data?.dueDate);
|
||||
const terms = requireStr(data?.terms);
|
||||
const scope = requireStr(data?.scope, 10000);
|
||||
const portalUrl = requireStr(data?.portalUrl);
|
||||
|
||||
subject = `Purchase Order ${esc(poNumber)} from Fourge Branding — ${esc(amount)}`;
|
||||
html = `
|
||||
<div style="font-family:sans-serif;max-width:560px;margin:0 auto;color:#1a1a1a;">
|
||||
<div style="background:#141414;padding:20px 28px;border-radius:8px 8px 0 0;">
|
||||
<img src="https://portal.fourgebranding.com/fourge-logo.png" alt="Fourge Branding" style="height:28px;" />
|
||||
</div>
|
||||
<div style="background:#fff;padding:28px;border:1px solid #e5e7eb;border-top:none;border-radius:0 0 8px 8px;">
|
||||
<h2 style="margin:0 0 8px;font-size:20px;">Hi ${esc(subcontractorName)},</h2>
|
||||
<p style="color:#555;margin:0 0 24px;">A subcontractor purchase order is ready for your review.</p>
|
||||
|
||||
<table style="width:100%;border-collapse:collapse;margin-bottom:24px;">
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">PO #</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(poNumber)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Project</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(projectName)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Company</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:700;text-align:right;">${esc(companyName)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Amount</td>
|
||||
<td style="padding:10px 14px;font-size:18px;font-weight:700;color:#141414;text-align:right;">${esc(amount)}</td>
|
||||
</tr>
|
||||
<tr style="background:#f9f9f9;">
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Due</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(dueDate)}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:10px 14px;font-size:13px;color:#666;">Terms</td>
|
||||
<td style="padding:10px 14px;font-size:13px;font-weight:600;text-align:right;">${esc(terms)}</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<div style="background:#f9f9f9;padding:12px 14px;border-radius:6px;font-size:13px;color:#555;margin-bottom:24px;">
|
||||
<strong>Scope</strong><br />
|
||||
${escMultiline(scope)}
|
||||
</div>
|
||||
|
||||
<a href="${esc(portalUrl)}" style="display:block;background:#141414;color:#fff;text-align:center;padding:14px;border-radius:8px;text-decoration:none;font-weight:700;font-size:16px;margin-bottom:20px;">Review and Approve PO</a>
|
||||
|
||||
<p style="font-size:12px;color:#999;text-align:center;margin:0;">
|
||||
Questions? <a href="mailto:hello@fourgebranding.com" style="color:#555;">hello@fourgebranding.com</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ── 5. Resolve recipients ────────────────────────────────────────────────
|
||||
const teamTypes = ['new_request', 'revision_submitted', 'client_approved'];
|
||||
let recipients: string[];
|
||||
let cc: string[] | undefined;
|
||||
|
||||
if (teamTypes.includes(type)) {
|
||||
recipients = TEAM_EMAILS;
|
||||
} else {
|
||||
// Validate caller-supplied recipient list
|
||||
const toList = Array.isArray(to) ? to : [to];
|
||||
recipients = [...new Set(toList.map(val => typeof val === 'string' ? val.trim() : val).filter(isEmail))];
|
||||
if (recipients.length === 0) throw new Error('No valid recipient emails');
|
||||
if (recipients.length > 20) throw new Error('Too many recipients');
|
||||
|
||||
const senderEmail = optStr(data?.senderEmail);
|
||||
if (senderEmail && isEmail(senderEmail)) cc = [senderEmail];
|
||||
if (type === 'invoice_sent') {
|
||||
cc = [...new Set([...(cc || []), 'hello@fourgebranding.com'])];
|
||||
}
|
||||
}
|
||||
|
||||
// ── 6. Send via Resend ───────────────────────────────────────────────────
|
||||
const payload: Record<string, unknown> = { from: FROM, to: recipients, subject, html };
|
||||
if (cc) payload.cc = cc;
|
||||
if (safeAttachments.length) payload.attachments = safeAttachments;
|
||||
|
||||
const res = await fetch('https://api.resend.com/emails', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${RESEND_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ from: FROM, to, subject, html }),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
const result = await res.json();
|
||||
const result = await parseJsonSafe(res);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
(result && typeof result === 'object' && 'message' in result && typeof result.message === 'string' && result.message)
|
||||
|| (result && typeof result === 'object' && 'error' in result && typeof result.error === 'string' && result.error)
|
||||
|| `Resend request failed with status ${res.status}`;
|
||||
console.error('Resend API error:', { status: res.status, result });
|
||||
return respond({ error: message, resend_status: res.status, resend_body: result }, 400);
|
||||
}
|
||||
|
||||
return new Response(JSON.stringify(result), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: res.ok ? 200 : 400,
|
||||
});
|
||||
return respond(result, 200);
|
||||
|
||||
} catch (err) {
|
||||
return new Response(JSON.stringify({ error: err.message }), {
|
||||
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
|
||||
status: 500,
|
||||
});
|
||||
console.error('send-email failed:', err);
|
||||
return respond({ error: (err as Error).message }, 500);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -17,38 +17,52 @@ serve(async (req) => {
|
||||
return new Response(`Webhook Error: ${err.message}`, { status: 400 });
|
||||
}
|
||||
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
// Helper: retrieve fee from a payment intent and mark invoice paid
|
||||
async function markPaid(paymentIntentId: string, invoice_id: string) {
|
||||
let stripe_fee: number | null = null;
|
||||
try {
|
||||
const pi = await stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ['latest_charge.balance_transaction'],
|
||||
});
|
||||
const charge = pi.latest_charge as Stripe.Charge | null;
|
||||
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
|
||||
if (balanceTx?.fee != null) stripe_fee = balanceTx.fee / 100;
|
||||
} catch (err) {
|
||||
console.error('Failed to retrieve Stripe fee:', err.message);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {
|
||||
status: 'paid',
|
||||
paid_at: new Date().toISOString(),
|
||||
};
|
||||
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
|
||||
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
|
||||
}
|
||||
|
||||
// Card payments: session completes with payment_status = 'paid' immediately
|
||||
if (event.type === 'checkout.session.completed') {
|
||||
const session = event.data.object as Stripe.Checkout.Session;
|
||||
const invoice_id = session.metadata?.invoice_id;
|
||||
const paymentIntentId = session.payment_intent as string | null;
|
||||
|
||||
// Only mark paid here for instant payment methods (card).
|
||||
// ACH sessions complete with payment_status 'unpaid' — let payment_intent.succeeded handle those.
|
||||
if (invoice_id && paymentIntentId && session.payment_status === 'paid') {
|
||||
await markPaid(paymentIntentId, invoice_id);
|
||||
}
|
||||
}
|
||||
|
||||
// ACH / async payment methods: payment_intent.succeeded fires when money actually settles
|
||||
if (event.type === 'payment_intent.succeeded') {
|
||||
const pi = event.data.object as Stripe.PaymentIntent;
|
||||
const invoice_id = pi.metadata?.invoice_id;
|
||||
if (invoice_id) {
|
||||
const supabase = createClient(
|
||||
Deno.env.get('SUPABASE_URL')!,
|
||||
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!,
|
||||
);
|
||||
|
||||
// Retrieve the Stripe processing fee from the balance transaction
|
||||
let stripe_fee: number | null = null;
|
||||
try {
|
||||
const paymentIntentId = session.payment_intent as string;
|
||||
if (paymentIntentId) {
|
||||
const paymentIntent = await stripe.paymentIntents.retrieve(paymentIntentId, {
|
||||
expand: ['latest_charge.balance_transaction'],
|
||||
});
|
||||
const charge = paymentIntent.latest_charge as Stripe.Charge | null;
|
||||
const balanceTx = charge?.balance_transaction as Stripe.BalanceTransaction | null;
|
||||
if (balanceTx?.fee != null) {
|
||||
stripe_fee = balanceTx.fee / 100;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to retrieve Stripe fee:', err.message);
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = { status: 'paid' };
|
||||
if (stripe_fee !== null) updateData.stripe_fee = stripe_fee;
|
||||
|
||||
await supabase.from('invoices').update(updateData).eq('id', invoice_id);
|
||||
await markPaid(pi.id, invoice_id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
create or replace function public.get_database_size_bytes()
|
||||
returns bigint
|
||||
language sql
|
||||
security definer
|
||||
set search_path = public
|
||||
as $$
|
||||
select pg_database_size(current_database());
|
||||
$$;
|
||||
|
||||
revoke all on function public.get_database_size_bytes() from public;
|
||||
grant execute on function public.get_database_size_bytes() to authenticated;
|
||||
grant execute on function public.get_database_size_bytes() to service_role;
|
||||
@@ -0,0 +1,22 @@
|
||||
create table if not exists public.server_status_overrides (
|
||||
id boolean primary key default true,
|
||||
supabase_egress_bytes bigint,
|
||||
vercel_fast_data_transfer_bytes bigint,
|
||||
vercel_edge_requests bigint,
|
||||
vercel_function_invocations bigint,
|
||||
vercel_active_cpu_hours numeric(10,4),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.server_status_overrides enable row level security;
|
||||
|
||||
drop policy if exists "Team all server_status_overrides" on public.server_status_overrides;
|
||||
create policy "Team all server_status_overrides"
|
||||
on public.server_status_overrides
|
||||
for all
|
||||
using (get_my_role() = 'team')
|
||||
with check (get_my_role() = 'team');
|
||||
|
||||
insert into public.server_status_overrides (id)
|
||||
values (true)
|
||||
on conflict (id) do nothing;
|
||||
@@ -0,0 +1,24 @@
|
||||
with affected_tasks as (
|
||||
select s.task_id
|
||||
from public.submissions s
|
||||
group by s.task_id
|
||||
having min(s.version_number) filter (where s.type <> 'amendment') = 1
|
||||
and count(*) filter (where s.type <> 'amendment' and s.version_number = 0) = 0
|
||||
)
|
||||
update public.submissions s
|
||||
set version_number = s.version_number - 1
|
||||
from affected_tasks a
|
||||
where s.task_id = a.task_id;
|
||||
|
||||
with corrected_versions as (
|
||||
select
|
||||
s.task_id,
|
||||
coalesce(max(s.version_number) filter (where s.type <> 'amendment'), 0) as corrected_current_version
|
||||
from public.submissions s
|
||||
group by s.task_id
|
||||
)
|
||||
update public.tasks t
|
||||
set current_version = cv.corrected_current_version
|
||||
from corrected_versions cv
|
||||
where t.id = cv.task_id
|
||||
and t.current_version is distinct from cv.corrected_current_version;
|
||||
@@ -0,0 +1,14 @@
|
||||
update public.submissions
|
||||
set service_type = 'Other'
|
||||
where coalesce(service_type, '') not in (
|
||||
'Logo Design',
|
||||
'Brand Identity',
|
||||
'Brand Guidelines',
|
||||
'Brand Book',
|
||||
'Social Media Graphics',
|
||||
'Print Design',
|
||||
'Business Cards',
|
||||
'Packaging Design',
|
||||
'Web Design',
|
||||
'Other'
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
alter table public.invoices add column if not exists bill_to text;
|
||||
@@ -0,0 +1,17 @@
|
||||
insert into storage.buckets (id, name, public)
|
||||
select 'fourge-files', 'fourge-files', false
|
||||
where not exists (
|
||||
select 1 from storage.buckets where id = 'fourge-files'
|
||||
);
|
||||
|
||||
create policy "Team reads fourge files storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (bucket_id = 'fourge-files' and get_my_role() = 'team');
|
||||
|
||||
create policy "Team inserts fourge files storage" on storage.objects
|
||||
for insert to authenticated
|
||||
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
|
||||
|
||||
create policy "Team deletes fourge files storage" on storage.objects
|
||||
for delete to authenticated
|
||||
using (bucket_id = 'fourge-files' and get_my_role() = 'team');
|
||||
+19
-21
@@ -1,9 +1,3 @@
|
||||
-- ============================================================
|
||||
-- Migration: Add external role and project_members table
|
||||
-- Run this in Supabase → SQL Editor → Run
|
||||
-- ============================================================
|
||||
|
||||
-- 1. Update profiles.role check constraint to include 'external'
|
||||
do $$
|
||||
declare
|
||||
cname text;
|
||||
@@ -13,7 +7,8 @@ begin
|
||||
where table_schema = 'public'
|
||||
and table_name = 'profiles'
|
||||
and constraint_type = 'CHECK'
|
||||
and constraint_name ilike '%role%';
|
||||
and constraint_name = 'profiles_role_check';
|
||||
|
||||
if cname is not null then
|
||||
execute 'alter table public.profiles drop constraint ' || quote_ident(cname);
|
||||
end if;
|
||||
@@ -23,48 +18,51 @@ $$;
|
||||
alter table public.profiles
|
||||
add constraint profiles_role_check check (role in ('team', 'client', 'external'));
|
||||
|
||||
-- 2. project_members table
|
||||
create table public.project_members (
|
||||
create table if not exists public.project_members (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
project_id uuid references public.projects(id) on delete cascade not null,
|
||||
profile_id uuid references public.profiles(id) on delete cascade not null,
|
||||
created_at timestamptz default now() not null,
|
||||
unique(project_id, profile_id)
|
||||
);
|
||||
|
||||
alter table public.project_members enable row level security;
|
||||
|
||||
-- 3. Helper function
|
||||
create or replace function public.is_external()
|
||||
returns boolean as $$
|
||||
select get_my_role() = 'external';
|
||||
$$ language sql security definer stable;
|
||||
|
||||
-- 4. RLS: project_members
|
||||
drop policy if exists "Team all project_members" on public.project_members;
|
||||
create policy "Team all project_members" on public.project_members
|
||||
for all using (get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "External reads own memberships" on public.project_members;
|
||||
create policy "External reads own memberships" on public.project_members
|
||||
for select using (profile_id = auth.uid());
|
||||
|
||||
-- 5. RLS: projects (external reads assigned only)
|
||||
drop policy if exists "External reads assigned projects" on public.projects;
|
||||
create policy "External reads assigned projects" on public.projects
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
id in (select project_id from public.project_members where profile_id = auth.uid())
|
||||
);
|
||||
|
||||
-- 6. RLS: tasks (external reads + updates assigned projects)
|
||||
drop policy if exists "External reads assigned tasks" on public.tasks;
|
||||
create policy "External reads assigned tasks" on public.tasks
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
project_id in (select project_id from public.project_members where profile_id = auth.uid())
|
||||
);
|
||||
|
||||
drop policy if exists "External updates assigned tasks" on public.tasks;
|
||||
create policy "External updates assigned tasks" on public.tasks
|
||||
for update using (
|
||||
get_my_role() = 'external' and
|
||||
project_id in (select project_id from public.project_members where profile_id = auth.uid())
|
||||
);
|
||||
|
||||
-- 7. RLS: submissions
|
||||
drop policy if exists "External reads assigned submissions" on public.submissions;
|
||||
create policy "External reads assigned submissions" on public.submissions
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
@@ -74,12 +72,14 @@ create policy "External reads assigned submissions" on public.submissions
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External inserts submissions" on public.submissions;
|
||||
create policy "External inserts submissions" on public.submissions
|
||||
for insert with check (
|
||||
get_my_role() = 'external' and submitted_by = auth.uid()
|
||||
);
|
||||
|
||||
-- 8. RLS: submission_files
|
||||
drop policy if exists "External reads assigned submission_files" on public.submission_files;
|
||||
create policy "External reads assigned submission_files" on public.submission_files
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
@@ -90,10 +90,12 @@ create policy "External reads assigned submission_files" on public.submission_fi
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External inserts submission_files" on public.submission_files;
|
||||
create policy "External inserts submission_files" on public.submission_files
|
||||
for insert with check (get_my_role() = 'external');
|
||||
|
||||
-- 9. RLS: deliveries (read only)
|
||||
drop policy if exists "External reads assigned deliveries" on public.deliveries;
|
||||
create policy "External reads assigned deliveries" on public.deliveries
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
@@ -105,7 +107,7 @@ create policy "External reads assigned deliveries" on public.deliveries
|
||||
)
|
||||
);
|
||||
|
||||
-- 10. RLS: delivery_files (read only)
|
||||
drop policy if exists "External reads assigned delivery_files" on public.delivery_files;
|
||||
create policy "External reads assigned delivery_files" on public.delivery_files
|
||||
for select using (
|
||||
get_my_role() = 'external' and
|
||||
@@ -117,7 +119,3 @@ create policy "External reads assigned delivery_files" on public.delivery_files
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- 11. RLS: profiles (external reads own profile only — already covered by existing policy)
|
||||
-- "Own profile select" policy already handles this with: id = auth.uid()
|
||||
-- No additional policy needed.
|
||||
@@ -0,0 +1,23 @@
|
||||
alter table public.submissions
|
||||
add column if not exists revision_type text;
|
||||
|
||||
do $$
|
||||
begin
|
||||
if not exists (
|
||||
select 1
|
||||
from pg_constraint
|
||||
where conname = 'submissions_revision_type_check'
|
||||
and conrelid = 'public.submissions'::regclass
|
||||
) then
|
||||
alter table public.submissions
|
||||
add constraint submissions_revision_type_check
|
||||
check (revision_type in ('fourge_error', 'client_revision'));
|
||||
end if;
|
||||
end;
|
||||
$$;
|
||||
|
||||
alter table public.submissions
|
||||
add column if not exists invoiced boolean not null default false;
|
||||
|
||||
alter table public.invoice_items
|
||||
add column if not exists submission_id uuid references public.submissions(id) on delete set null;
|
||||
@@ -0,0 +1,28 @@
|
||||
drop policy if exists "Client inserts submission_files" on public.submission_files;
|
||||
drop policy if exists "External inserts submission_files" on public.submission_files;
|
||||
|
||||
create policy "Client inserts submission_files" on public.submission_files
|
||||
for insert with check (
|
||||
get_my_role() = 'client'
|
||||
and submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
and s.submitted_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
create policy "External inserts submission_files" on public.submission_files
|
||||
for insert with check (
|
||||
get_my_role() = 'external'
|
||||
and submission_id in (
|
||||
select s.id
|
||||
from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
and s.submitted_by = auth.uid()
|
||||
)
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
drop policy if exists "Own profile update" on public.profiles;
|
||||
|
||||
create policy "Own profile update" on public.profiles
|
||||
for update
|
||||
using (id = auth.uid())
|
||||
with check (
|
||||
id = auth.uid()
|
||||
and role = (select role from public.profiles where id = auth.uid())
|
||||
and company_id is not distinct from (select company_id from public.profiles where id = auth.uid())
|
||||
);
|
||||
@@ -0,0 +1,118 @@
|
||||
drop policy if exists "Auth users upload to submissions" on storage.objects;
|
||||
drop policy if exists "Auth users read submissions" on storage.objects;
|
||||
drop policy if exists "Team upload deliveries" on storage.objects;
|
||||
drop policy if exists "Auth users read deliveries" on storage.objects;
|
||||
|
||||
drop policy if exists "Team reads submissions storage" on storage.objects;
|
||||
create policy "Team reads submissions storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (bucket_id = 'submissions' and get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Client reads submissions storage" on storage.objects;
|
||||
create policy "Client reads submissions storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (
|
||||
bucket_id = 'submissions'
|
||||
and get_my_role() = 'client'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External reads submissions storage" on storage.objects;
|
||||
create policy "External reads submissions storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (
|
||||
bucket_id = 'submissions'
|
||||
and get_my_role() = 'external'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Team inserts submissions storage" on storage.objects;
|
||||
create policy "Team inserts submissions storage" on storage.objects
|
||||
for insert to authenticated
|
||||
with check (bucket_id = 'submissions' and get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Client inserts submissions storage" on storage.objects;
|
||||
create policy "Client inserts submissions storage" on storage.objects
|
||||
for insert to authenticated
|
||||
with check (
|
||||
bucket_id = 'submissions'
|
||||
and get_my_role() = 'client'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External inserts submissions storage" on storage.objects;
|
||||
create policy "External inserts submissions storage" on storage.objects
|
||||
for insert to authenticated
|
||||
with check (
|
||||
bucket_id = 'submissions'
|
||||
and get_my_role() = 'external'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Team deletes submissions storage" on storage.objects;
|
||||
create policy "Team deletes submissions storage" on storage.objects
|
||||
for delete to authenticated
|
||||
using (bucket_id = 'submissions' and get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Team reads deliveries storage" on storage.objects;
|
||||
create policy "Team reads deliveries storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (bucket_id = 'deliveries' and get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Client reads deliveries storage" on storage.objects;
|
||||
create policy "Client reads deliveries storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (
|
||||
bucket_id = 'deliveries'
|
||||
and get_my_role() = 'client'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.projects p on p.id = t.project_id
|
||||
where p.company_id = get_my_company_id()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "External reads deliveries storage" on storage.objects;
|
||||
create policy "External reads deliveries storage" on storage.objects
|
||||
for select to authenticated
|
||||
using (
|
||||
bucket_id = 'deliveries'
|
||||
and get_my_role() = 'external'
|
||||
and split_part(name, '/', 1) in (
|
||||
select t.id::text
|
||||
from public.tasks t
|
||||
join public.project_members pm on pm.project_id = t.project_id
|
||||
where pm.profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Team inserts deliveries storage" on storage.objects;
|
||||
create policy "Team inserts deliveries storage" on storage.objects
|
||||
for insert to authenticated
|
||||
with check (bucket_id = 'deliveries' and get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "Team deletes deliveries storage" on storage.objects;
|
||||
create policy "Team deletes deliveries storage" on storage.objects
|
||||
for delete to authenticated
|
||||
using (bucket_id = 'deliveries' and get_my_role() = 'team');
|
||||
@@ -0,0 +1,11 @@
|
||||
drop policy if exists "Client updates own company" on public.companies;
|
||||
create policy "Client updates own company" on public.companies
|
||||
for update
|
||||
using (id = get_my_company_id())
|
||||
with check (id = get_my_company_id());
|
||||
|
||||
drop policy if exists "Client updates own company projects" on public.projects;
|
||||
create policy "Client updates own company projects" on public.projects
|
||||
for update
|
||||
using (company_id = get_my_company_id())
|
||||
with check (company_id = get_my_company_id());
|
||||
@@ -0,0 +1,4 @@
|
||||
create policy "Team updates fourge files storage" on storage.objects
|
||||
for update to authenticated
|
||||
using (bucket_id = 'fourge-files' and get_my_role() = 'team')
|
||||
with check (bucket_id = 'fourge-files' and get_my_role() = 'team');
|
||||
@@ -0,0 +1,18 @@
|
||||
create table if not exists public.fourge_passwords (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
service_name text not null,
|
||||
service_url text default '',
|
||||
username text not null default '',
|
||||
encrypted_password text not null,
|
||||
password_iv text not null,
|
||||
notes text default '',
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz not null default now(),
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
alter table public.fourge_passwords enable row level security;
|
||||
|
||||
create policy "Team all fourge_passwords" on public.fourge_passwords
|
||||
for all using (get_my_role() = 'team')
|
||||
with check (get_my_role() = 'team');
|
||||
@@ -0,0 +1,14 @@
|
||||
create table public.expenses (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
date date default current_date not null,
|
||||
description text not null,
|
||||
category text not null default 'Other',
|
||||
amount numeric(10,2) not null,
|
||||
notes text default '',
|
||||
receipt_path text,
|
||||
receipt_name text,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
alter table public.expenses enable row level security;
|
||||
create policy "Team all expenses" on public.expenses for all using (get_my_role() = 'team');
|
||||
@@ -0,0 +1 @@
|
||||
alter table public.invoices add column if not exists paid_at timestamptz;
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table public.expenses add column if not exists receipt_path text;
|
||||
alter table public.expenses add column if not exists receipt_name text;
|
||||
@@ -0,0 +1,22 @@
|
||||
alter table public.expenses
|
||||
add column if not exists receipt_path text,
|
||||
add column if not exists receipt_name text;
|
||||
|
||||
insert into storage.buckets (id, name, public)
|
||||
values ('expense-receipts', 'expense-receipts', false)
|
||||
on conflict (id) do nothing;
|
||||
|
||||
drop policy if exists "Team reads expense receipts storage" on storage.objects;
|
||||
drop policy if exists "Team inserts expense receipts storage" on storage.objects;
|
||||
drop policy if exists "Team updates expense receipts storage" on storage.objects;
|
||||
drop policy if exists "Team deletes expense receipts storage" on storage.objects;
|
||||
|
||||
create policy "Team reads expense receipts storage" on storage.objects
|
||||
for select to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team inserts expense receipts storage" on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team updates expense receipts storage" on storage.objects
|
||||
for update to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team')
|
||||
with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team deletes expense receipts storage" on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
@@ -0,0 +1,153 @@
|
||||
create table if not exists public.company_members (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
company_id uuid references public.companies(id) on delete cascade not null,
|
||||
profile_id uuid references public.profiles(id) on delete cascade not null,
|
||||
created_at timestamptz default now() not null,
|
||||
unique(company_id, profile_id)
|
||||
);
|
||||
|
||||
alter table public.company_members enable row level security;
|
||||
|
||||
insert into public.company_members (company_id, profile_id)
|
||||
select company_id, id
|
||||
from public.profiles
|
||||
where company_id is not null
|
||||
on conflict (company_id, profile_id) do nothing;
|
||||
|
||||
create or replace function public.has_company_access(company uuid)
|
||||
returns boolean as $$
|
||||
select exists (
|
||||
select 1
|
||||
from public.profiles p
|
||||
where p.id = auth.uid()
|
||||
and (
|
||||
p.company_id = company
|
||||
or exists (
|
||||
select 1
|
||||
from public.company_members cm
|
||||
where cm.profile_id = auth.uid()
|
||||
and cm.company_id = company
|
||||
)
|
||||
)
|
||||
);
|
||||
$$ language sql security definer stable;
|
||||
|
||||
drop policy if exists "Team all company_members" on public.company_members;
|
||||
drop policy if exists "Users read own company memberships" on public.company_members;
|
||||
create policy "Team all company_members" on public.company_members
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
create policy "Users read own company memberships" on public.company_members
|
||||
for select using (profile_id = auth.uid());
|
||||
|
||||
drop policy if exists "Client reads own company" on public.companies;
|
||||
drop policy if exists "Client updates own company" on public.companies;
|
||||
create policy "Client reads assigned companies" on public.companies
|
||||
for select using (has_company_access(id));
|
||||
create policy "Client updates primary company" on public.companies
|
||||
for update using (id = get_my_company_id()) with check (id = get_my_company_id());
|
||||
|
||||
drop policy if exists "Client reads company projects" on public.projects;
|
||||
drop policy if exists "Client inserts company projects" on public.projects;
|
||||
drop policy if exists "Client updates own company projects" on public.projects;
|
||||
create policy "Client reads assigned company projects" on public.projects
|
||||
for select using (has_company_access(company_id));
|
||||
create policy "Client inserts assigned company projects" on public.projects
|
||||
for insert with check (get_my_role() = 'client' and has_company_access(company_id));
|
||||
create policy "Client updates assigned company projects" on public.projects
|
||||
for update using (get_my_role() = 'client' and has_company_access(company_id))
|
||||
with check (get_my_role() = 'client' and has_company_access(company_id));
|
||||
|
||||
drop policy if exists "Client reads company tasks" on public.tasks;
|
||||
drop policy if exists "Client insert task" on public.tasks;
|
||||
drop policy if exists "Client updates company tasks" on public.tasks;
|
||||
create policy "Client reads assigned company tasks" on public.tasks for select using (
|
||||
project_id in (select id from public.projects where has_company_access(company_id))
|
||||
);
|
||||
create policy "Client inserts assigned company tasks" on public.tasks for insert with check (
|
||||
get_my_role() = 'client'
|
||||
and project_id in (select id from public.projects where has_company_access(company_id))
|
||||
);
|
||||
create policy "Client updates assigned company tasks" on public.tasks
|
||||
for update
|
||||
using (
|
||||
get_my_role() = 'client'
|
||||
and project_id in (select id from public.projects where has_company_access(company_id))
|
||||
)
|
||||
with check (
|
||||
get_my_role() = 'client'
|
||||
and project_id in (select id from public.projects where has_company_access(company_id))
|
||||
);
|
||||
|
||||
drop policy if exists "Client reads company submissions" on public.submissions;
|
||||
drop policy if exists "Client inserts submissions" on public.submissions;
|
||||
create policy "Client reads assigned company submissions" on public.submissions for select using (
|
||||
task_id in (
|
||||
select t.id from public.tasks t
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
)
|
||||
);
|
||||
create policy "Client inserts assigned company submissions" on public.submissions for insert with check (
|
||||
get_my_role() = 'client'
|
||||
and submitted_by = auth.uid()
|
||||
and task_id in (
|
||||
select t.id from public.tasks t
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Client reads company submission_files" on public.submission_files;
|
||||
drop policy if exists "Client inserts submission_files" on public.submission_files;
|
||||
create policy "Client reads assigned company submission_files" on public.submission_files for select using (
|
||||
submission_id in (
|
||||
select s.id from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
)
|
||||
);
|
||||
create policy "Client inserts assigned company submission_files" on public.submission_files for insert with check (
|
||||
get_my_role() = 'client'
|
||||
and submission_id in (
|
||||
select s.id from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
and s.submitted_by = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Client reads company deliveries" on public.deliveries;
|
||||
create policy "Client reads assigned company deliveries" on public.deliveries for select using (
|
||||
submission_id in (
|
||||
select s.id from public.submissions s
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Client reads company delivery_files" on public.delivery_files;
|
||||
create policy "Client reads assigned company delivery_files" on public.delivery_files for select using (
|
||||
delivery_id in (
|
||||
select d.id from public.deliveries d
|
||||
join public.submissions s on s.id = d.submission_id
|
||||
join public.tasks t on t.id = s.task_id
|
||||
join public.projects p on p.id = t.project_id
|
||||
where has_company_access(p.company_id)
|
||||
)
|
||||
);
|
||||
|
||||
drop policy if exists "Client reads own company prices" on public.company_prices;
|
||||
create policy "Client reads assigned company prices" on public.company_prices
|
||||
for select using (has_company_access(company_id));
|
||||
|
||||
drop policy if exists "Client reads company invoices" on public.invoices;
|
||||
create policy "Client reads assigned company invoices" on public.invoices
|
||||
for select using (has_company_access(company_id));
|
||||
|
||||
drop policy if exists "Client reads company invoice_items" on public.invoice_items;
|
||||
create policy "Client reads assigned company invoice_items" on public.invoice_items for select using (
|
||||
invoice_id in (select id from public.invoices where has_company_access(company_id))
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table public.invoices
|
||||
add column if not exists invoice_email text;
|
||||
@@ -0,0 +1,18 @@
|
||||
create table if not exists public.subcontractor_payments (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
profile_id uuid references public.profiles(id) on delete set null,
|
||||
date date default current_date not null,
|
||||
description text not null,
|
||||
amount numeric(10,2) not null,
|
||||
status text not null default 'pending' check (status in ('pending', 'paid')),
|
||||
paid_at date,
|
||||
notes text default '',
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
|
||||
alter table public.subcontractor_payments enable row level security;
|
||||
|
||||
drop policy if exists "Team all subcontractor_payments" on public.subcontractor_payments;
|
||||
create policy "Team all subcontractor_payments" on public.subcontractor_payments
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
@@ -0,0 +1,9 @@
|
||||
delete from public.company_members cm
|
||||
using public.profiles p
|
||||
where p.id = cm.profile_id
|
||||
and p.role = 'external';
|
||||
|
||||
update public.profiles
|
||||
set company_id = null
|
||||
where role = 'external'
|
||||
and company_id is not null;
|
||||
@@ -0,0 +1,52 @@
|
||||
alter table public.subcontractor_payments
|
||||
add column if not exists po_number text,
|
||||
add column if not exists project_id uuid references public.projects(id) on delete set null,
|
||||
add column if not exists due_date date,
|
||||
add column if not exists terms text default 'Net 15',
|
||||
add column if not exists sent_at timestamptz,
|
||||
add column if not exists approved_at timestamptz,
|
||||
add column if not exists cancelled_at timestamptz;
|
||||
|
||||
alter table public.subcontractor_payments
|
||||
drop constraint if exists subcontractor_payments_status_check;
|
||||
|
||||
update public.subcontractor_payments
|
||||
set status = 'ready_to_pay'
|
||||
where status = 'pending';
|
||||
|
||||
alter table public.subcontractor_payments
|
||||
add constraint subcontractor_payments_status_check
|
||||
check (status in ('draft', 'sent', 'approved', 'ready_to_pay', 'paid', 'cancelled'));
|
||||
|
||||
with numbered as (
|
||||
select
|
||||
id,
|
||||
'PO-' || to_char(coalesce(created_at, now()), 'YYYY') || '-' || lpad((row_number() over (order by created_at, id))::text, 4, '0') as generated_po_number
|
||||
from public.subcontractor_payments
|
||||
where po_number is null
|
||||
)
|
||||
update public.subcontractor_payments sp
|
||||
set po_number = numbered.generated_po_number
|
||||
from numbered
|
||||
where sp.id = numbered.id;
|
||||
|
||||
create unique index if not exists subcontractor_payments_po_number_key
|
||||
on public.subcontractor_payments(po_number)
|
||||
where po_number is not null;
|
||||
|
||||
drop policy if exists "External reads own subcontractor purchase orders" on public.subcontractor_payments;
|
||||
create policy "External reads own subcontractor purchase orders" on public.subcontractor_payments
|
||||
for select using (
|
||||
get_my_role() = 'external'
|
||||
and profile_id = auth.uid()
|
||||
);
|
||||
|
||||
drop policy if exists "External updates own subcontractor purchase orders" on public.subcontractor_payments;
|
||||
create policy "External updates own subcontractor purchase orders" on public.subcontractor_payments
|
||||
for update using (
|
||||
get_my_role() = 'external'
|
||||
and profile_id = auth.uid()
|
||||
) with check (
|
||||
get_my_role() = 'external'
|
||||
and profile_id = auth.uid()
|
||||
);
|
||||
@@ -0,0 +1,33 @@
|
||||
create table if not exists public.subcontractor_po_items (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
po_id uuid references public.subcontractor_payments(id) on delete cascade not null,
|
||||
task_id uuid references public.tasks(id) on delete set null,
|
||||
description text not null,
|
||||
amount numeric(10,2) not null,
|
||||
sort_order integer default 0 not null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
|
||||
alter table public.subcontractor_po_items enable row level security;
|
||||
|
||||
drop policy if exists "Team all subcontractor_po_items" on public.subcontractor_po_items;
|
||||
create policy "Team all subcontractor_po_items" on public.subcontractor_po_items
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
|
||||
drop policy if exists "External reads own subcontractor_po_items" on public.subcontractor_po_items;
|
||||
create policy "External reads own subcontractor_po_items" on public.subcontractor_po_items
|
||||
for select using (
|
||||
get_my_role() = 'external'
|
||||
and po_id in (
|
||||
select id from public.subcontractor_payments
|
||||
where profile_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
insert into public.subcontractor_po_items (po_id, task_id, description, amount, sort_order)
|
||||
select id, null, description, amount, 0
|
||||
from public.subcontractor_payments sp
|
||||
where not exists (
|
||||
select 1 from public.subcontractor_po_items item
|
||||
where item.po_id = sp.id
|
||||
);
|
||||
@@ -0,0 +1,10 @@
|
||||
update public.projects
|
||||
set name = 'Untitled Project ' || left(id::text, 8)
|
||||
where length(trim(coalesce(name, ''))) = 0;
|
||||
|
||||
alter table public.projects
|
||||
drop constraint if exists projects_name_not_blank;
|
||||
|
||||
alter table public.projects
|
||||
add constraint projects_name_not_blank
|
||||
check (length(trim(name)) > 0);
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
create or replace function public.prevent_duplicate_active_subcontractor_po_task()
|
||||
returns trigger as $$
|
||||
declare
|
||||
current_po_status text;
|
||||
duplicate_po_number text;
|
||||
begin
|
||||
if new.task_id is null then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select status into current_po_status
|
||||
from public.subcontractor_payments
|
||||
where id = new.po_id;
|
||||
|
||||
if current_po_status = 'cancelled' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select sp.po_number into duplicate_po_number
|
||||
from public.subcontractor_po_items item
|
||||
join public.subcontractor_payments sp on sp.id = item.po_id
|
||||
where item.task_id = new.task_id
|
||||
and item.id <> coalesce(new.id, '00000000-0000-0000-0000-000000000000'::uuid)
|
||||
and sp.status <> 'cancelled'
|
||||
limit 1;
|
||||
|
||||
if duplicate_po_number is not null then
|
||||
raise exception 'Task is already attached to active subcontractor PO %', duplicate_po_number
|
||||
using errcode = '23505';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
drop trigger if exists prevent_duplicate_active_subcontractor_po_task on public.subcontractor_po_items;
|
||||
create trigger prevent_duplicate_active_subcontractor_po_task
|
||||
before insert or update of task_id, po_id on public.subcontractor_po_items
|
||||
for each row execute function public.prevent_duplicate_active_subcontractor_po_task();
|
||||
|
||||
create or replace function public.prevent_duplicate_on_subcontractor_po_reactivation()
|
||||
returns trigger as $$
|
||||
declare
|
||||
duplicate_task_title text;
|
||||
begin
|
||||
if old.status = new.status or new.status = 'cancelled' then
|
||||
return new;
|
||||
end if;
|
||||
|
||||
select t.title into duplicate_task_title
|
||||
from public.subcontractor_po_items current_item
|
||||
join public.subcontractor_po_items other_item
|
||||
on other_item.task_id = current_item.task_id
|
||||
and other_item.po_id <> current_item.po_id
|
||||
join public.subcontractor_payments other_po
|
||||
on other_po.id = other_item.po_id
|
||||
and other_po.status <> 'cancelled'
|
||||
left join public.tasks t on t.id = current_item.task_id
|
||||
where current_item.po_id = new.id
|
||||
and current_item.task_id is not null
|
||||
limit 1;
|
||||
|
||||
if duplicate_task_title is not null then
|
||||
raise exception 'Cannot reactivate PO because task "%" is already attached to another active subcontractor PO', duplicate_task_title
|
||||
using errcode = '23505';
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
drop trigger if exists prevent_duplicate_on_subcontractor_po_reactivation on public.subcontractor_payments;
|
||||
create trigger prevent_duplicate_on_subcontractor_po_reactivation
|
||||
before update of status on public.subcontractor_payments
|
||||
for each row execute function public.prevent_duplicate_on_subcontractor_po_reactivation();
|
||||
@@ -0,0 +1,30 @@
|
||||
-- Allow client revision returns to clear stale assignments while keeping
|
||||
-- assignment fields protected for normal client updates.
|
||||
create or replace function public.guard_task_update()
|
||||
returns trigger as $$
|
||||
declare
|
||||
caller_role text;
|
||||
begin
|
||||
select role into caller_role from public.profiles where id = auth.uid();
|
||||
|
||||
if caller_role = 'client' then
|
||||
new.project_id := old.project_id;
|
||||
new.invoiced := old.invoiced;
|
||||
|
||||
if not (
|
||||
new.status = 'not_started'
|
||||
and coalesce(new.current_version, 0) > coalesce(old.current_version, 0)
|
||||
and new.assigned_to is null
|
||||
and new.assigned_name is null
|
||||
) then
|
||||
new.assigned_to := old.assigned_to;
|
||||
new.assigned_name := old.assigned_name;
|
||||
end if;
|
||||
elsif caller_role = 'external' then
|
||||
new.project_id := old.project_id;
|
||||
new.invoiced := old.invoiced;
|
||||
end if;
|
||||
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
@@ -0,0 +1,15 @@
|
||||
create table public.meeting_notes (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
meeting_at timestamptz default now() not null,
|
||||
title text not null,
|
||||
attendees text default '',
|
||||
notes text not null default '',
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null,
|
||||
updated_at timestamptz default now() not null
|
||||
);
|
||||
|
||||
alter table public.meeting_notes enable row level security;
|
||||
|
||||
create policy "Team all meeting_notes" on public.meeting_notes
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
@@ -0,0 +1,2 @@
|
||||
alter table public.submissions
|
||||
add column if not exists is_hot boolean not null default false;
|
||||
@@ -0,0 +1,16 @@
|
||||
alter table public.tasks
|
||||
add column if not exists request_key uuid;
|
||||
|
||||
alter table public.submissions
|
||||
add column if not exists request_key uuid;
|
||||
|
||||
create unique index if not exists tasks_request_key_key
|
||||
on public.tasks (request_key)
|
||||
where request_key is not null;
|
||||
|
||||
create unique index if not exists submissions_request_key_key
|
||||
on public.submissions (request_key)
|
||||
where request_key is not null;
|
||||
|
||||
create unique index if not exists projects_company_normalized_name_key
|
||||
on public.projects (company_id, lower(btrim(name)));
|
||||
@@ -0,0 +1,27 @@
|
||||
-- Auto-add subcontractor to project_members when a PO is created or updated
|
||||
-- so RLS allows them to read all tasks in their assigned project.
|
||||
|
||||
create or replace function public.sync_subcontractor_project_member()
|
||||
returns trigger as $$
|
||||
begin
|
||||
if new.profile_id is not null and new.project_id is not null then
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
values (new.project_id, new.profile_id)
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
end if;
|
||||
return new;
|
||||
end;
|
||||
$$ language plpgsql security definer;
|
||||
|
||||
drop trigger if exists trg_sync_subcontractor_project_member on public.subcontractor_payments;
|
||||
create trigger trg_sync_subcontractor_project_member
|
||||
after insert or update of profile_id, project_id
|
||||
on public.subcontractor_payments
|
||||
for each row execute function public.sync_subcontractor_project_member();
|
||||
|
||||
-- Backfill existing POs
|
||||
insert into public.project_members (project_id, profile_id)
|
||||
select distinct project_id, profile_id
|
||||
from public.subcontractor_payments
|
||||
where profile_id is not null and project_id is not null
|
||||
on conflict (project_id, profile_id) do nothing;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Remove allowed MIME type restrictions from submissions and deliveries buckets
|
||||
-- so clients and team can upload any file type (ZIP, etc.)
|
||||
update storage.buckets
|
||||
set allowed_mime_types = null
|
||||
where id in ('submissions', 'deliveries');
|
||||
@@ -0,0 +1,20 @@
|
||||
-- Allow clients to delete tasks in their own company's projects.
|
||||
-- Previously only team members could delete tasks (no client delete policy existed),
|
||||
-- so client-side "Delete Request" silently failed with 0 rows deleted.
|
||||
|
||||
drop policy if exists "Client deletes company tasks" on public.tasks;
|
||||
create policy "Client deletes company tasks" on public.tasks
|
||||
for delete using (
|
||||
get_my_role() = 'client'
|
||||
and project_id in (
|
||||
select id from public.projects where has_company_access(company_id)
|
||||
)
|
||||
);
|
||||
|
||||
-- Also allow clients to delete projects (needed when the last task in a project is deleted).
|
||||
drop policy if exists "Client deletes company projects" on public.projects;
|
||||
create policy "Client deletes company projects" on public.projects
|
||||
for delete using (
|
||||
get_my_role() = 'client'
|
||||
and has_company_access(company_id)
|
||||
);
|
||||
@@ -1,13 +0,0 @@
|
||||
-- Add cover page fields to brand_books table
|
||||
ALTER TABLE brand_books
|
||||
ADD COLUMN IF NOT EXISTS creation_date date,
|
||||
ADD COLUMN IF NOT EXISTS revision_date date,
|
||||
ADD COLUMN IF NOT EXISTS customer_name text,
|
||||
ADD COLUMN IF NOT EXISTS customer_address text,
|
||||
ADD COLUMN IF NOT EXISTS project_logo_path text,
|
||||
ADD COLUMN IF NOT EXISTS client_logo_url text,
|
||||
ADD COLUMN IF NOT EXISTS client_contact_name text,
|
||||
ADD COLUMN IF NOT EXISTS client_contact_email text,
|
||||
ADD COLUMN IF NOT EXISTS client_contact_phone text,
|
||||
ADD COLUMN IF NOT EXISTS approved_date date,
|
||||
ADD COLUMN IF NOT EXISTS approval_notes text;
|
||||
@@ -1,4 +0,0 @@
|
||||
-- Add template and inventory map fields to brand_books table
|
||||
ALTER TABLE brand_books
|
||||
ADD COLUMN IF NOT EXISTS template text DEFAULT 'fourge',
|
||||
ADD COLUMN IF NOT EXISTS inventory_map_path text;
|
||||
@@ -1,29 +0,0 @@
|
||||
-- Add brand book / cover page fields to companies
|
||||
ALTER TABLE companies
|
||||
ADD COLUMN IF NOT EXISTS address text,
|
||||
ADD COLUMN IF NOT EXISTS contact_name text,
|
||||
ADD COLUMN IF NOT EXISTS contact_email text,
|
||||
ADD COLUMN IF NOT EXISTS contact_phone text,
|
||||
ADD COLUMN IF NOT EXISTS client_logo_url text;
|
||||
|
||||
-- Create public bucket for company logos
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM storage.buckets WHERE id = 'company-logos') THEN
|
||||
INSERT INTO storage.buckets (id, name, public) VALUES ('company-logos', 'company-logos', true);
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Storage policies for company-logos
|
||||
DROP POLICY IF EXISTS "Authenticated users can manage company logos" ON storage.objects;
|
||||
DROP POLICY IF EXISTS "Public can read company logos" ON storage.objects;
|
||||
|
||||
CREATE POLICY "Authenticated users can manage company logos"
|
||||
ON storage.objects FOR ALL
|
||||
TO authenticated
|
||||
USING (bucket_id = 'company-logos')
|
||||
WITH CHECK (bucket_id = 'company-logos');
|
||||
|
||||
CREATE POLICY "Public can read company logos"
|
||||
ON storage.objects FOR SELECT
|
||||
USING (bucket_id = 'company-logos');
|
||||
@@ -1,17 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: Add price_type to company_prices (new vs revision)
|
||||
-- Run in Supabase → SQL Editor → Run
|
||||
-- ============================================================
|
||||
|
||||
-- Add price_type column (existing rows default to 'new')
|
||||
alter table public.company_prices
|
||||
add column price_type text not null default 'new'
|
||||
check (price_type in ('new', 'revision'));
|
||||
|
||||
-- Drop old unique constraint and add new one that includes price_type
|
||||
alter table public.company_prices
|
||||
drop constraint company_prices_company_id_service_type_key;
|
||||
|
||||
alter table public.company_prices
|
||||
add constraint company_prices_company_id_service_type_price_type_key
|
||||
unique (company_id, service_type, price_type);
|
||||
@@ -1,15 +0,0 @@
|
||||
-- ============================================================
|
||||
-- Migration: Add revision billing tracking
|
||||
-- Run in Supabase → SQL Editor → Run
|
||||
-- ============================================================
|
||||
|
||||
-- Add revision_type and invoiced to submissions
|
||||
alter table public.submissions
|
||||
add column revision_type text check (revision_type in ('fourge_error', 'client_revision'));
|
||||
|
||||
alter table public.submissions
|
||||
add column invoiced boolean not null default false;
|
||||
|
||||
-- Add submission_id to invoice_items (links a line item to a specific revision)
|
||||
alter table public.invoice_items
|
||||
add column submission_id uuid references public.submissions(id) on delete set null;
|
||||
+87
-2
@@ -33,6 +33,7 @@ create table public.projects (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
company_id uuid references public.companies(id) on delete cascade not null,
|
||||
name text not null,
|
||||
constraint projects_name_not_blank check (length(trim(name)) > 0),
|
||||
description text default '',
|
||||
status text not null check (status in ('active', 'completed')) default 'active',
|
||||
created_at timestamptz default now() not null
|
||||
@@ -43,6 +44,7 @@ alter table public.projects enable row level security;
|
||||
create table public.tasks (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
project_id uuid references public.projects(id) on delete cascade not null,
|
||||
request_key uuid,
|
||||
title text not null,
|
||||
assigned_to uuid references public.profiles(id) on delete set null,
|
||||
assigned_name text,
|
||||
@@ -58,10 +60,12 @@ alter table public.tasks enable row level security;
|
||||
create table public.submissions (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
task_id uuid references public.tasks(id) on delete cascade not null,
|
||||
request_key uuid,
|
||||
version_number integer not null,
|
||||
type text not null check (type in ('initial', 'revision', 'amendment')) default 'initial',
|
||||
revision_type text check (revision_type in ('fourge_error', 'client_revision')),
|
||||
invoiced boolean not null default false,
|
||||
is_hot boolean not null default false,
|
||||
service_type text default '',
|
||||
deadline date,
|
||||
description text default '',
|
||||
@@ -71,6 +75,17 @@ create table public.submissions (
|
||||
);
|
||||
alter table public.submissions enable row level security;
|
||||
|
||||
create unique index if not exists tasks_request_key_key
|
||||
on public.tasks (request_key)
|
||||
where request_key is not null;
|
||||
|
||||
create unique index if not exists submissions_request_key_key
|
||||
on public.submissions (request_key)
|
||||
where request_key is not null;
|
||||
|
||||
create unique index if not exists projects_company_normalized_name_key
|
||||
on public.projects (company_id, lower(btrim(name)));
|
||||
|
||||
-- Submission Files
|
||||
create table public.submission_files (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
@@ -132,12 +147,62 @@ create table public.invoices (
|
||||
due_date date,
|
||||
total numeric(10,2) default 0,
|
||||
notes text default '',
|
||||
bill_to text,
|
||||
invoice_email text,
|
||||
stripe_fee numeric(10,2),
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
alter table public.invoices enable row level security;
|
||||
|
||||
-- Expenses
|
||||
create table public.expenses (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
date date default current_date not null,
|
||||
description text not null,
|
||||
category text not null default 'Other',
|
||||
amount numeric(10,2) not null,
|
||||
notes text default '',
|
||||
receipt_path text,
|
||||
receipt_name text,
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
alter table public.expenses enable row level security;
|
||||
create policy "Team all expenses" on public.expenses for all using (get_my_role() = 'team');
|
||||
|
||||
-- Subcontractor Payments (tracked separately, included as contractor expenses)
|
||||
create table public.subcontractor_payments (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
profile_id uuid references public.profiles(id) on delete set null,
|
||||
date date default current_date not null,
|
||||
description text not null,
|
||||
amount numeric(10,2) not null,
|
||||
status text not null default 'pending' check (status in ('pending', 'paid')),
|
||||
paid_at date,
|
||||
notes text default '',
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null
|
||||
);
|
||||
alter table public.subcontractor_payments enable row level security;
|
||||
create policy "Team all subcontractor_payments" on public.subcontractor_payments
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
|
||||
-- Meeting Notes
|
||||
create table public.meeting_notes (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
meeting_at timestamptz default now() not null,
|
||||
title text not null,
|
||||
attendees text default '',
|
||||
notes text not null default '',
|
||||
created_by uuid references public.profiles(id) on delete set null,
|
||||
created_at timestamptz default now() not null,
|
||||
updated_at timestamptz default now() not null
|
||||
);
|
||||
alter table public.meeting_notes enable row level security;
|
||||
create policy "Team all meeting_notes" on public.meeting_notes
|
||||
for all using (get_my_role() = 'team') with check (get_my_role() = 'team');
|
||||
|
||||
-- Invoice Items
|
||||
create table public.invoice_items (
|
||||
id uuid default gen_random_uuid() primary key,
|
||||
@@ -243,9 +308,17 @@ begin
|
||||
|
||||
if caller_role = 'client' then
|
||||
new.project_id := old.project_id;
|
||||
new.assigned_to := old.assigned_to;
|
||||
new.assigned_name := old.assigned_name;
|
||||
new.invoiced := old.invoiced;
|
||||
|
||||
if not (
|
||||
new.status = 'not_started'
|
||||
and coalesce(new.current_version, 0) > coalesce(old.current_version, 0)
|
||||
and new.assigned_to is null
|
||||
and new.assigned_name is null
|
||||
) then
|
||||
new.assigned_to := old.assigned_to;
|
||||
new.assigned_name := old.assigned_name;
|
||||
end if;
|
||||
elsif caller_role = 'external' then
|
||||
new.project_id := old.project_id;
|
||||
new.invoiced := old.invoiced;
|
||||
@@ -474,6 +547,7 @@ insert into storage.buckets (id, name, public) values ('submissions', 'submissio
|
||||
insert into storage.buckets (id, name, public) values ('deliveries', 'deliveries', false);
|
||||
insert into storage.buckets (id, name, public) values ('company-logos', 'company-logos', true);
|
||||
insert into storage.buckets (id, name, public) values ('fourge-files', 'fourge-files', false);
|
||||
insert into storage.buckets (id, name, public) values ('expense-receipts', 'expense-receipts', false);
|
||||
|
||||
-- Company Logos (public bucket — team manages, public can read for embedded URLs in PDFs)
|
||||
create policy "Team manages company logos" on storage.objects
|
||||
@@ -546,6 +620,17 @@ create policy "Team updates fourge files storage" on storage.objects
|
||||
create policy "Team deletes fourge files storage" on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'fourge-files' and get_my_role() = 'team');
|
||||
|
||||
-- Expense Receipts: team-only storage for uploaded expense receipts/photos
|
||||
create policy "Team reads expense receipts storage" on storage.objects
|
||||
for select to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team inserts expense receipts storage" on storage.objects
|
||||
for insert to authenticated with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team updates expense receipts storage" on storage.objects
|
||||
for update to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team')
|
||||
with check (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
create policy "Team deletes expense receipts storage" on storage.objects
|
||||
for delete to authenticated using (bucket_id = 'expense-receipts' and get_my_role() = 'team');
|
||||
|
||||
-- ============================================================
|
||||
-- Trigger: auto-create profile on signup
|
||||
-- Team assigns company_id later via the Companies page
|
||||
|
||||
Reference in New Issue
Block a user