Fix webhook failing to update plan for pre-existing Supabase users
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m17s
Dev — Build & local smoke test / build-smoke (pull_request) Successful in 1m17s
supabase_admin_invite re-sends a magic link for users who already have
an account (e.g. signed up via attestation before paying) but does not
touch their user_metadata — leaving plan as "free" after purchase.
Fix: add supabase_admin_update_user (PUT /auth/v1/admin/users/{id})
and call it after every invite so user_metadata is always stamped with
the correct plan, name, and stripe_customer_id. Idempotent for new and
returning users.
Also fix waitlist_upsert to use on_conflict=email,plan so the upsert
works for users who already have a waitlist row from attestation,
rather than silently failing on duplicate key.
This commit is contained in:
Vendored
+49
@@ -237,6 +237,55 @@ el_val_t supabase_admin_invite(el_val_t project_url, el_val_t service_key, el_va
|
||||
return http_post_with_headers(EL_STR(url), body_json, headers);
|
||||
}
|
||||
|
||||
/*
|
||||
* supabase_admin_update_user — PUT {project_url}/auth/v1/admin/users/{user_id}
|
||||
* with the service-role key to overwrite a user's user_metadata (and any other
|
||||
* top-level fields in body_json). Unlike /auth/v1/invite, this always writes
|
||||
* the supplied data even when the user already exists.
|
||||
*
|
||||
* body_json example:
|
||||
* {"user_metadata":{"plan":"founding","stripe_customer_id":"cus_xxx","name":"..."}}
|
||||
*
|
||||
* Returns the raw JSON response from Supabase (includes the updated user object).
|
||||
* Returns "" on transport error.
|
||||
*
|
||||
* Used by the Stripe webhook after supabase_admin_invite to guarantee the
|
||||
* plan is stamped correctly regardless of whether the account was created
|
||||
* before or after payment.
|
||||
*/
|
||||
el_val_t supabase_admin_update_user(el_val_t project_url, el_val_t service_key,
|
||||
el_val_t user_id, el_val_t body_json) {
|
||||
CURL *c = curl_easy_init();
|
||||
if (!c) return EL_STR("");
|
||||
char url[1024];
|
||||
snprintf(url, sizeof(url), "%s/auth/v1/admin/users/%s",
|
||||
EL_CSTR(project_url), EL_CSTR(user_id));
|
||||
char auth_hdr[2048];
|
||||
snprintf(auth_hdr, sizeof(auth_hdr), "Authorization: Bearer %s", EL_CSTR(service_key));
|
||||
char api_hdr[2048];
|
||||
snprintf(api_hdr, sizeof(api_hdr), "apikey: %s", EL_CSTR(service_key));
|
||||
struct curl_slist *hdrs = NULL;
|
||||
hdrs = curl_slist_append(hdrs, auth_hdr);
|
||||
hdrs = curl_slist_append(hdrs, api_hdr);
|
||||
hdrs = curl_slist_append(hdrs, "Content-Type: application/json");
|
||||
hdrs = curl_slist_append(hdrs, "Accept: application/json");
|
||||
_stub_resp_t r = {0};
|
||||
curl_easy_setopt(c, CURLOPT_URL, url);
|
||||
curl_easy_setopt(c, CURLOPT_CUSTOMREQUEST, "PUT");
|
||||
curl_easy_setopt(c, CURLOPT_POSTFIELDS, EL_CSTR(body_json));
|
||||
curl_easy_setopt(c, CURLOPT_HTTPHEADER, hdrs);
|
||||
curl_easy_setopt(c, CURLOPT_FOLLOWLOCATION, 1L);
|
||||
curl_easy_setopt(c, CURLOPT_TIMEOUT, 60L);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEFUNCTION, _stub_write);
|
||||
curl_easy_setopt(c, CURLOPT_WRITEDATA, &r);
|
||||
CURLcode rc = curl_easy_perform(c);
|
||||
curl_easy_cleanup(c);
|
||||
curl_slist_free_all(hdrs);
|
||||
if (rc != CURLE_OK) { free(r.buf); return EL_STR(""); }
|
||||
if (!r.buf) return EL_STR("");
|
||||
return EL_STR(r.buf);
|
||||
}
|
||||
|
||||
/*
|
||||
* gcs_get_token — fetch an OAuth2 bearer token.
|
||||
*
|
||||
|
||||
+17
-2
@@ -415,8 +415,10 @@ fn waitlist_upsert(email: String, name: String, plan: String, source: String, at
|
||||
let ua_safe: String = str_replace(str_replace(user_agent, "\\", "\\\\"), "\"", "\\\"")
|
||||
let num_field: String = if member_num > 0 { ",\"member_number\":" + int_to_str(member_num) } else { "" }
|
||||
let row: String = "{\"email\":\"" + e_safe + "\",\"name\":\"" + n_safe + "\",\"plan\":\"" + plan + "\",\"source\":\"" + source + "\",\"attestation\":\"" + a_safe + "\",\"user_agent\":\"" + ua_safe + "\"" + num_field + "}"
|
||||
let resp: String = supabase_insert(sb_url, sb_key, "waitlist", row)
|
||||
println("[waitlist] supabase insert -> " + resp)
|
||||
// Use on_conflict=email,plan so existing rows are updated (upsert)
|
||||
// rather than silently failing on duplicate key.
|
||||
let resp: String = supabase_insert(sb_url, sb_key, "waitlist?on_conflict=email,plan", row)
|
||||
println("[waitlist] supabase upsert -> " + resp)
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -1620,6 +1622,19 @@ fn handle_request_inner(method: String, path: String, headers: Map, body: String
|
||||
let _cust_resp: String = http_post_form_auth(cust_url, cust_body, stripe_auth)
|
||||
}
|
||||
}
|
||||
// Always stamp user_metadata directly via Admin API.
|
||||
// supabase_admin_invite re-sends a magic link for existing users
|
||||
// but does NOT update their user_metadata — so plan stays "free"
|
||||
// for anyone who signed up (attestation, waitlist) before paying.
|
||||
// This PUT is idempotent: safe for both new and returning users.
|
||||
if !str_eq(new_user_id, "") {
|
||||
let meta_body: String = "{\"user_metadata\":{\"plan\":\"" + plan_safe + "\""
|
||||
+ ",\"name\":\"" + name_safe + "\""
|
||||
+ ",\"stripe_customer_id\":\"" + cid_safe2 + "\""
|
||||
+ ",\"email_verified\":true}}"
|
||||
let _meta_resp: String = supabase_admin_update_user(wb_sb_url, wb_sb_key, new_user_id, meta_body)
|
||||
println("[webhook] supabase user_metadata update for " + new_user_id + ": " + _meta_resp)
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Forward to license API for key provisioning
|
||||
|
||||
Reference in New Issue
Block a user