mirror of
https://github.com/opensourcepos/opensourcepos.git
synced 2026-09-14 06:19:44 -04:00
Add three missing plugin hook points for sale documents so plugins can
inject buttons into invoice, quote, and work order views alongside the
existing receipt hook. All four pass ['saleId' => $sale_id_num] and
follow the same naming pattern (view:sales_{type}_buttons).
Exempt plugins/*/webhook from CSRF filtering to support server-to-server
provider callbacks. Also convert the CSRF except list from a string to an
array — the previous 'login|migrate' string produced a single unanchored
pattern, making login/anything CSRF-exempt. Separate array entries anchor
each one individually. Plugin webhook handlers are responsible for their
own authentication.
Add WhatsApp Business Cloud API plugin (app/Plugins/WhatsAppPlugin/):
- Free-form messaging page registered as the 'whatsapp' office module
with its own permission, plus per-customer modal and thread view
- "Send via WhatsApp" button on all four sale document types via the new
hooks; renders only when the customer has a phone number
- PDF delivery using the same sales/{type}_email view core uses for email
- Inbound webhook at plugins/whatsapp/webhook authenticated by
X-Hub-Signature-256 HMAC; fails closed on missing/bad signature;
always returns 200 to suppress Meta retries
- Out-of-order status callbacks cannot downgrade sent → delivered → read
- Conversation log table via plugin migration; dropped on uninstall with
version reset so re-install recreates it cleanly
- Access token and app secret encrypted at rest in plugin_config; no
writes to app_config or initial_schema.sql
- utf8mb4_unicode_520_ci collation throughout (MySQL and MariaDB compat)
- Language file stubs for all existing locales; English strings complete
- README covering credentials, install, webhook setup, and uninstall
133 lines
4.4 KiB
PHP
133 lines
4.4 KiB
PHP
<?php
|
|
|
|
namespace App\Plugins\WhatsAppPlugin\Models;
|
|
|
|
use CodeIgniter\Database\ResultInterface;
|
|
use CodeIgniter\Model;
|
|
use ReflectionException;
|
|
|
|
/**
|
|
* Persists the WhatsApp conversation log: outbound messages the plugin sends and
|
|
* inbound replies received via the webhook.
|
|
*/
|
|
class WhatsAppMessage extends Model
|
|
{
|
|
protected $table = 'whatsapp_messages';
|
|
protected $primaryKey = 'message_id';
|
|
protected $useAutoIncrement = true;
|
|
protected $useSoftDeletes = false;
|
|
protected $returnType = 'object';
|
|
protected $allowedFields = [
|
|
'person_id',
|
|
'phone',
|
|
'direction',
|
|
'type',
|
|
'body',
|
|
'media_id',
|
|
'filename',
|
|
'wa_message_id',
|
|
'status',
|
|
'error',
|
|
'created_at',
|
|
];
|
|
|
|
/**
|
|
* Records one message — outbound or inbound — in the conversation log.
|
|
*
|
|
* @return int The inserted message_id, or 0 on failure.
|
|
*
|
|
* @throws ReflectionException
|
|
*/
|
|
public function storeWhatsAppMessage(array $data): int
|
|
{
|
|
if (empty($data['created_at'])) {
|
|
$data['created_at'] = date('Y-m-d H:i:s');
|
|
}
|
|
|
|
return $this->insert($data, true) ? (int) $this->getInsertID() : 0;
|
|
}
|
|
|
|
/**
|
|
* Meta redelivers a webhook whose acknowledgement it did not see, so the same
|
|
* inbound message can arrive twice. The unique index on wa_message_id is the
|
|
* backstop; this keeps a redelivery from becoming a failed insert part-way
|
|
* through a payload.
|
|
*/
|
|
public function existsByWaMessageId(string $waMessageId): bool
|
|
{
|
|
return $this->db->table('whatsapp_messages')
|
|
->where('wa_message_id', $waMessageId)
|
|
->countAllResults() > 0;
|
|
}
|
|
|
|
/**
|
|
* @param string $phone Normalized phone number (digits only).
|
|
*/
|
|
public function getConversation(string $phone, int $limit = 200): ResultInterface
|
|
{
|
|
$builder = $this->db->table('whatsapp_messages');
|
|
$builder->where('phone', $phone);
|
|
$builder->orderBy('created_at', 'asc');
|
|
$builder->orderBy('message_id', 'asc');
|
|
$builder->limit($limit);
|
|
|
|
return $builder->get();
|
|
}
|
|
|
|
/**
|
|
* Distinct phone numbers with a conversation, most recently active first.
|
|
*/
|
|
public function getRecentConversations(int $limit = 50): array
|
|
{
|
|
// Grouped by phone only: inbound webhook messages and general outbound
|
|
// sends log person_id => null, while sale sends log a real person_id.
|
|
// Grouping on both would split one customer's thread into two rows.
|
|
$builder = $this->db->table('whatsapp_messages');
|
|
$builder->select('phone, MAX(person_id) AS person_id, MAX(created_at) AS last_activity, COUNT(*) AS message_count');
|
|
$builder->groupBy('phone');
|
|
$builder->orderBy('last_activity', 'desc');
|
|
$builder->limit($limit);
|
|
|
|
return $builder->get()->getResultArray();
|
|
}
|
|
|
|
/**
|
|
* Updates the delivery status of an outbound message, identified by its
|
|
* WhatsApp message id (wamid).
|
|
*
|
|
* Status callbacks can arrive out of order, so backward transitions along the
|
|
* sent -> delivered -> read progression are ignored to stop a late "delivered"
|
|
* downgrading an already "read" message. Statuses outside that ordering
|
|
* (e.g. "failed") are always applied.
|
|
*
|
|
* The ordering lives in the WHERE clause rather than in a read-then-compare,
|
|
* because two callbacks for the same message can be handled concurrently and
|
|
* the later write would otherwise win on a stale comparison.
|
|
*
|
|
* @return bool False when the statement itself fails. A skipped backward
|
|
* transition and an unknown wamid both count as success.
|
|
*/
|
|
public function updateStatus(string $waMessageId, string $status): bool
|
|
{
|
|
$rank = ['sent' => 1, 'delivered' => 2, 'read' => 3];
|
|
|
|
$builder = $this->db->table('whatsapp_messages')->where('wa_message_id', $waMessageId);
|
|
|
|
if (isset($rank[$status])) {
|
|
$overtakes = array_keys(array_filter($rank, static fn (int $r): bool => $r < $rank[$status]));
|
|
|
|
$builder->groupStart()
|
|
->where('status', null)
|
|
->orWhereNotIn('status', array_keys($rank));
|
|
|
|
if ($overtakes !== []) {
|
|
$builder->orWhereIn('status', $overtakes);
|
|
}
|
|
|
|
$builder->groupEnd();
|
|
}
|
|
|
|
return $builder->update(['status' => $status]);
|
|
}
|
|
}
|