(optional; lets a parent key send as a campus sub-account) * { "messages": [ { "content", "destination_number", "source_number", "format": "SMS", * "delivery_report": true, "metadata": {...}, "message_expiry_timestamp" } ] } * * Regional hosts: https://us.app.api.sinch.com (US), https://eu.app.api.sinch.com, https://au.app.api.sinch.com * * Config: SINCH_API_BASE, SINCH_API_KEY, SINCH_API_SECRET, SINCH_DEFAULT_SOURCE_NUMBER, * SINCH_USE_CAMPUS_SUBACCOUNTS (default true), SINCH_INTERNAL_NOTIFY_NUMBER (optional) * * Idempotency: a lead is only sent once. The DB row is marked "sending" before the * request and updated with the provider message_id after; retries skip rows that are * not in a retryable state. */ final class Sinch { public const TEMPLATE_DEFAULT = "Hi {{first_name}} — we're excited that you're considering Waters Church {{campus_name}}. Your visit details: {{service_or_date}}. Have a question? Reply to this message. Reply STOP to opt out."; /** * @param array $lead * @param array $campus * @return array{status:string, detail:?string, message_id:?string, transient:bool} */ public static function sendVisitConfirmation(array $lead, array $campus): array { if (!(int)$lead['sms_consent']) { return ['status' => 'skipped', 'detail' => 'no sms consent', 'message_id' => null, 'transient' => false]; } $to = $lead['phone_e164'] ?? null; if (!$to || !Phone::isE164($to)) { return ['status' => 'failed', 'detail' => 'no valid phone', 'message_id' => null, 'transient' => false]; } $content = self::buildConfirmation($lead, $campus); $from = self::sourceNumberFor($campus); $account = self::accountFor($campus); if (Config::isMock()) { Log::info('[mock] Sinch send', ['account' => $account, 'source' => $from, 'chars' => mb_strlen($content)]); return ['status' => 'mock', 'detail' => 'mock mode; would send from ' . ($from ?? 'default') . ($account ? " as $account" : ''), 'message_id' => 'mock-' . substr($lead['submission_id'], 0, 8), 'transient' => false]; } $message = [ 'content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'delivery_report' => true, 'metadata' => ['submission_id' => $lead['submission_id'], 'campus' => $campus['id'], 'source' => 'visit.waterschurch.org'], 'message_expiry_timestamp' => gmdate('Y-m-d\TH:i:s\Z', time() + 6 * 3600), ]; if ($from) { $message['source_number'] = $from; } $r = self::post('/v1/messages', ['messages' => [$message]], $account); if ($r['status'] === 202 || $r['status'] === 200) { $msg = $r['json']['messages'][0] ?? []; $id = is_array($msg) ? ($msg['message_id'] ?? null) : null; $status = is_array($msg) ? ($msg['status'] ?? 'queued') : 'queued'; // Only claim success when the provider actually accepted the message. if (!$id) { return ['status' => 'failed', 'detail' => 'provider response missing message_id', 'message_id' => null, 'transient' => true]; } return ['status' => 'sent', 'detail' => "provider status: $status", 'message_id' => $id, 'transient' => false]; } $detail = is_array($r['json']) ? json_encode($r['json']) : ($r['error'] ?? "HTTP {$r['status']}"); Log::warn('Sinch send failed', ['status' => $r['status'], 'detail' => substr((string)$detail, 0, 300)]); return ['status' => 'failed', 'detail' => substr((string)$detail, 0, 500), 'message_id' => null, 'transient' => Http::isTransient($r['status'], $r['error'])]; } /** * Optional internal heads-up to campus staff. Enabled only when * SINCH_INTERNAL_NOTIFY_NUMBER is set. Contains no visitor PII beyond first name. * * @param array $lead * @param array $campus */ public static function sendInternalLeadNotification(array $lead, array $campus): ?array { $to = Config::get('SINCH_INTERNAL_NOTIFY_NUMBER'); if (!$to) { return null; } $content = sprintf('New first-time visitor plan: %s → %s%s. View in the leads export.', $lead['first_name'], $campus['name'], $lead['visit_date'] ? ' on ' . $lead['visit_date'] : ''); if (Config::isMock()) { Log::info('[mock] Sinch internal notify', ['to' => $to]); return ['status' => 'mock']; } $r = self::post('/v1/messages', ['messages' => [['content' => $content, 'destination_number' => $to, 'format' => 'SMS', 'source_number' => Config::get('SINCH_DEFAULT_SOURCE_NUMBER')]]], null); return ['status' => ($r['status'] === 202 || $r['status'] === 200) ? 'sent' : 'failed']; } public static function normalizePhoneNumber(string $raw): ?string { return Phone::normalizeNanp($raw); } /** * @param array $lead * @param array $campus */ public static function buildConfirmation(array $lead, array $campus): string { $template = Config::get('SINCH_CONFIRMATION_TEMPLATE') ?: self::TEMPLATE_DEFAULT; $when = self::serviceOrDate($lead, $campus); $vars = [ '{{first_name}}' => $lead['first_name'], '{{campus_name}}' => $campus['name'], '{{service_or_date}}' => $when, '{{address}}' => $campus['address']['line1'] . ', ' . $campus['address']['city'], ]; $text = strtr($template, $vars); $text = preg_replace('/\s+/', ' ', trim($text)) ?? $text; // Keep to a sane length; carriers concatenate but cost rises. 320 chars = 2 segments (GSM) or ~4 (UCS-2). return mb_substr($text, 0, 320); } /** * @param array $lead * @param array $campus */ public static function serviceOrDate(array $lead, array $campus): string { $parts = []; if (!empty($lead['visit_date'])) { $ts = strtotime($lead['visit_date']); $parts[] = $ts ? date('D, M j', $ts) : $lead['visit_date']; } if (!empty($lead['service_time'])) { $parts[] = $lead['service_time']; } if (!$parts) { $parts[] = Campuses::timesSummary($campus); } return implode(' · ', $parts); } /** @param array $campus */ public static function sourceNumberFor(array $campus): ?string { if (Config::bool('SINCH_USE_CAMPUS_NUMBERS', true) && !empty($campus['smsNumber'])) { return $campus['smsNumber']; } return Config::get('SINCH_DEFAULT_SOURCE_NUMBER'); } /** @param array $campus */ public static function accountFor(array $campus): ?string { if (Config::bool('SINCH_USE_CAMPUS_SUBACCOUNTS', true) && !empty($campus['sinchAccountId'])) { return $campus['sinchAccountId']; } return Config::get('SINCH_ACCOUNT_ID'); } /** * @param array $payload * @return array{status:int, body:string, json:mixed, error:?string} */ private static function post(string $path, array $payload, ?string $account): array { $base = rtrim(Config::get('SINCH_API_BASE', 'https://us.app.api.sinch.com') ?? '', '/'); $key = Config::require('SINCH_API_KEY'); $secret = Config::require('SINCH_API_SECRET'); $headers = ['Authorization' => 'Basic ' . base64_encode("$key:$secret")]; if ($account) { $headers['Account'] = $account; } return Http::json('POST', $base . $path, $payload, $headers, 10); } }