mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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' },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user