Add ACMCC app source, Supabase backend, and project config

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-01 20:19:26 -04:00
parent 313b51b412
commit 183fe0a93c
1422 changed files with 259271 additions and 0 deletions
+240
View File
@@ -0,0 +1,240 @@
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',
};
async function md5Lower(s: string) {
const data = new TextEncoder().encode(s.toLowerCase());
// Mailchimp requires MD5 of lowercase email as the subscriber hash
const hash = await crypto.subtle.digest('MD5', data).catch(() => null);
if (hash) {
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
}
// Fallback: use a JS MD5 implementation
return jsMd5(s.toLowerCase());
}
// Minimal JS MD5 fallback (Deno's WebCrypto may not support MD5 in some runtimes)
function jsMd5(str: string): string {
function rotateLeft(x: number, n: number) { return (x << n) | (x >>> (32 - n)); }
function addUnsigned(x: number, y: number) {
const x4 = (x & 0x40000000); const y4 = (y & 0x40000000);
const x8 = (x & 0x80000000); const y8 = (y & 0x80000000);
const result = (x & 0x3FFFFFFF) + (y & 0x3FFFFFFF);
if (x4 & y4) return (result ^ 0x80000000 ^ x8 ^ y8);
if (x4 | y4) {
if (result & 0x40000000) return (result ^ 0xC0000000 ^ x8 ^ y8);
else return (result ^ 0x40000000 ^ x8 ^ y8);
} else return (result ^ x8 ^ y8);
}
function f(x:number,y:number,z:number){return (x&y)|((~x)&z);}
function g(x:number,y:number,z:number){return (x&z)|(y&(~z));}
function h(x:number,y:number,z:number){return x^y^z;}
function i(x:number,y:number,z:number){return y^(x|(~z));}
function ff(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(f(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
function gg(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(g(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
function hh(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(h(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
function ii(a:number,b:number,c:number,d:number,x:number,s:number,ac:number){a=addUnsigned(a,addUnsigned(addUnsigned(i(b,c,d),x),ac));return addUnsigned(rotateLeft(a,s),b);}
function convertToWordArray(str:string){
let lWordCount; const lMessageLength=str.length;
const lNumberOfWordsTempOne=lMessageLength+8;
const lNumberOfWordsTempTwo=(lNumberOfWordsTempOne-(lNumberOfWordsTempOne%64))/64;
const lNumberOfWords=(lNumberOfWordsTempTwo+1)*16;
const lWordArray=new Array(lNumberOfWords-1).fill(0);
let lBytePosition=0; let lByteCount=0;
while(lByteCount<lMessageLength){
lWordCount=(lByteCount-(lByteCount%4))/4;
lBytePosition=(lByteCount%4)*8;
lWordArray[lWordCount]=(lWordArray[lWordCount]|(str.charCodeAt(lByteCount)<<lBytePosition));
lByteCount++;
}
lWordCount=(lByteCount-(lByteCount%4))/4;
lBytePosition=(lByteCount%4)*8;
lWordArray[lWordCount]=lWordArray[lWordCount]|(0x80<<lBytePosition);
lWordArray[lNumberOfWords-2]=lMessageLength<<3;
lWordArray[lNumberOfWords-1]=lMessageLength>>>29;
return lWordArray;
}
function wordToHex(lValue:number){let wordToHexValue="",wordToHexValueTemp="",lByte,lCount;for(lCount=0;lCount<=3;lCount++){lByte=(lValue>>>(lCount*8))&255;wordToHexValueTemp="0"+lByte.toString(16);wordToHexValue=wordToHexValue+wordToHexValueTemp.substr(wordToHexValueTemp.length-2,2);}return wordToHexValue;}
function utf8Encode(str:string){return unescape(encodeURIComponent(str));}
const x=convertToWordArray(utf8Encode(str));
let a=0x67452301,b=0xEFCDAB89,c=0x98BADCFE,d=0x10325476;
const S11=7,S12=12,S13=17,S14=22,S21=5,S22=9,S23=14,S24=20,S31=4,S32=11,S33=16,S34=23,S41=6,S42=10,S43=15,S44=21;
for(let k=0;k<x.length;k+=16){
const AA=a,BB=b,CC=c,DD=d;
a=ff(a,b,c,d,x[k+0],S11,0xD76AA478); d=ff(d,a,b,c,x[k+1],S12,0xE8C7B756); c=ff(c,d,a,b,x[k+2],S13,0x242070DB); b=ff(b,c,d,a,x[k+3],S14,0xC1BDCEEE);
a=ff(a,b,c,d,x[k+4],S11,0xF57C0FAF); d=ff(d,a,b,c,x[k+5],S12,0x4787C62A); c=ff(c,d,a,b,x[k+6],S13,0xA8304613); b=ff(b,c,d,a,x[k+7],S14,0xFD469501);
a=ff(a,b,c,d,x[k+8],S11,0x698098D8); d=ff(d,a,b,c,x[k+9],S12,0x8B44F7AF); c=ff(c,d,a,b,x[k+10],S13,0xFFFF5BB1); b=ff(b,c,d,a,x[k+11],S14,0x895CD7BE);
a=ff(a,b,c,d,x[k+12],S11,0x6B901122); d=ff(d,a,b,c,x[k+13],S12,0xFD987193); c=ff(c,d,a,b,x[k+14],S13,0xA679438E); b=ff(b,c,d,a,x[k+15],S14,0x49B40821);
a=gg(a,b,c,d,x[k+1],S21,0xF61E2562); d=gg(d,a,b,c,x[k+6],S22,0xC040B340); c=gg(c,d,a,b,x[k+11],S23,0x265E5A51); b=gg(b,c,d,a,x[k+0],S24,0xE9B6C7AA);
a=gg(a,b,c,d,x[k+5],S21,0xD62F105D); d=gg(d,a,b,c,x[k+10],S22,0x2441453); c=gg(c,d,a,b,x[k+15],S23,0xD8A1E681); b=gg(b,c,d,a,x[k+4],S24,0xE7D3FBC8);
a=gg(a,b,c,d,x[k+9],S21,0x21E1CDE6); d=gg(d,a,b,c,x[k+14],S22,0xC33707D6); c=gg(c,d,a,b,x[k+3],S23,0xF4D50D87); b=gg(b,c,d,a,x[k+8],S24,0x455A14ED);
a=gg(a,b,c,d,x[k+13],S21,0xA9E3E905); d=gg(d,a,b,c,x[k+2],S22,0xFCEFA3F8); c=gg(c,d,a,b,x[k+7],S23,0x676F02D9); b=gg(b,c,d,a,x[k+12],S24,0x8D2A4C8A);
a=hh(a,b,c,d,x[k+5],S31,0xFFFA3942); d=hh(d,a,b,c,x[k+8],S32,0x8771F681); c=hh(c,d,a,b,x[k+11],S33,0x6D9D6122); b=hh(b,c,d,a,x[k+14],S34,0xFDE5380C);
a=hh(a,b,c,d,x[k+1],S31,0xA4BEEA44); d=hh(d,a,b,c,x[k+4],S32,0x4BDECFA9); c=hh(c,d,a,b,x[k+7],S33,0xF6BB4B60); b=hh(b,c,d,a,x[k+10],S34,0xBEBFBC70);
a=hh(a,b,c,d,x[k+13],S31,0x289B7EC6); d=hh(d,a,b,c,x[k+0],S32,0xEAA127FA); c=hh(c,d,a,b,x[k+3],S33,0xD4EF3085); b=hh(b,c,d,a,x[k+6],S34,0x4881D05);
a=hh(a,b,c,d,x[k+9],S31,0xD9D4D039); d=hh(d,a,b,c,x[k+12],S32,0xE6DB99E5); c=hh(c,d,a,b,x[k+15],S33,0x1FA27CF8); b=hh(b,c,d,a,x[k+2],S34,0xC4AC5665);
a=ii(a,b,c,d,x[k+0],S41,0xF4292244); d=ii(d,a,b,c,x[k+7],S42,0x432AFF97); c=ii(c,d,a,b,x[k+14],S43,0xAB9423A7); b=ii(b,c,d,a,x[k+5],S44,0xFC93A039);
a=ii(a,b,c,d,x[k+12],S41,0x655B59C3); d=ii(d,a,b,c,x[k+3],S42,0x8F0CCC92); c=ii(c,d,a,b,x[k+10],S43,0xFFEFF47D); b=ii(b,c,d,a,x[k+1],S44,0x85845DD1);
a=ii(a,b,c,d,x[k+8],S41,0x6FA87E4F); d=ii(d,a,b,c,x[k+15],S42,0xFE2CE6E0); c=ii(c,d,a,b,x[k+6],S43,0xA3014314); b=ii(b,c,d,a,x[k+13],S44,0x4E0811A1);
a=ii(a,b,c,d,x[k+4],S41,0xF7537E82); d=ii(d,a,b,c,x[k+11],S42,0xBD3AF235); c=ii(c,d,a,b,x[k+2],S43,0x2AD7D2BB); b=ii(b,c,d,a,x[k+9],S44,0xEB86D391);
a=addUnsigned(a,AA); b=addUnsigned(b,BB); c=addUnsigned(c,CC); d=addUnsigned(d,DD);
}
return (wordToHex(a)+wordToHex(b)+wordToHex(c)+wordToHex(d)).toLowerCase();
}
Deno.serve(async (req) => {
if (req.method === 'OPTIONS') {
return new Response(null, { headers: corsHeaders });
}
try {
const authHeader = req.headers.get('Authorization');
if (!authHeader) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const supabase = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_ANON_KEY')!,
{ global: { headers: { Authorization: authHeader } } }
);
const admin = createClient(
Deno.env.get('SUPABASE_URL')!,
Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const { association_id } = await req.json();
if (!association_id) {
return new Response(JSON.stringify({ error: 'Missing association_id' }), {
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Load config
const { data: config, error: cfgErr } = await admin
.from('mailchimp_configs')
.select('*')
.eq('association_id', association_id)
.maybeSingle();
if (cfgErr || !config) {
return new Response(JSON.stringify({ error: 'Mailchimp not configured for this association' }), {
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
if (!config.audience_id) {
return new Response(JSON.stringify({ error: 'No audience selected' }), {
status: 400, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Load owners
const { data: owners, error: ownErr } = await admin
.from('owners')
.select('first_name, last_name, email, property_address, electronic_consent')
.eq('association_id', association_id)
.eq('status', 'active')
.not('email', 'is', null);
if (ownErr) {
return new Response(JSON.stringify({ error: ownErr.message }), {
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
const baseUrl = `https://${config.server_prefix}.api.mailchimp.com/3.0`;
const authMc = `Basic ${btoa(`anystring:${config.api_key}`)}`;
// Use Mailchimp batch operations endpoint for efficient PUT (upsert) of members
const operations: any[] = [];
const validOwners = (owners || []).filter(o => o.email && o.email.includes('@'));
for (const o of validOwners) {
const hash = await md5Lower(o.email);
operations.push({
method: 'PUT',
path: `/lists/${config.audience_id}/members/${hash}`,
body: JSON.stringify({
email_address: o.email,
status_if_new: 'subscribed',
merge_fields: {
FNAME: o.first_name || '',
LNAME: o.last_name || '',
ADDRESS: o.property_address || '',
},
}),
});
}
let succeeded = 0;
let failed = 0;
let lastError: string | null = null;
if (operations.length === 0) {
await admin.from('mailchimp_configs').update({
last_sync_at: new Date().toISOString(),
last_sync_status: 'success',
last_sync_count: 0,
last_sync_error: null,
}).eq('id', config.id);
return new Response(JSON.stringify({ success: true, total: 0, succeeded: 0, failed: 0 }), {
headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
// Send in chunks of 100 sequentially via individual PUT calls (simpler than batch endpoint)
for (let i = 0; i < operations.length; i++) {
const op = operations[i];
try {
const r = await fetch(`${baseUrl}${op.path}`, {
method: op.method,
headers: { Authorization: authMc, 'Content-Type': 'application/json' },
body: op.body,
});
if (r.ok) {
succeeded++;
} else {
failed++;
const txt = await r.text();
lastError = txt.slice(0, 300);
}
} catch (e) {
failed++;
lastError = (e as Error).message;
}
}
const status = failed === 0 ? 'success' : (succeeded === 0 ? 'failed' : 'partial');
await admin.from('mailchimp_configs').update({
last_sync_at: new Date().toISOString(),
last_sync_status: status,
last_sync_count: succeeded,
last_sync_error: lastError,
}).eq('id', config.id);
return new Response(JSON.stringify({
success: true,
total: operations.length,
succeeded,
failed,
error: lastError,
}), { headers: { ...corsHeaders, 'Content-Type': 'application/json' } });
} catch (err) {
return new Response(JSON.stringify({ error: (err as Error).message }), {
status: 500, headers: { ...corsHeaders, 'Content-Type': 'application/json' },
});
}
});