feat: full scaffolding — manifests, Extension, provider, SQL (7 tables), 4 helpers (Catalog, Checkout, Reservation, Fine), language files
Universal: Auto Version Bump / Version Bump (push) Successful in 10s
Universal: Pre-Release / Build Pre-Release (${{ inputs.stability || github.ref_name }}) (push) Successful in 12s

This commit is contained in:
Jonathan Miller
2026-06-23 11:01:13 -05:00
parent a5366dbca8
commit fffdd32f64
12 changed files with 768 additions and 0 deletions
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library"
PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines."
@@ -0,0 +1,2 @@
PLG_SYSTEM_MOKOSUITELIBRARY="System - MokoSuite Library"
PLG_SYSTEM_MOKOSUITELIBRARY_DESC="Library and resource lending — catalog, checkout, returns, reservations, overdue tracking, fines."
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="plugin" group="system" method="upgrade">
<name>System - MokoSuite Library</name>
<element>mokosuitelibrary</element>
<author>Moko Consulting</author>
<creationDate>2026-06-23</creationDate>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GPL-3.0-or-later</license>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<version>01.00.00</version>
<php_minimum>8.3</php_minimum>
<description>PLG_SYSTEM_MOKOSUITELIBRARY_DESC</description>
<namespace path="src">Moko\Plugin\System\MokoSuiteLibrary</namespace>
<files>
<folder>src</folder>
<folder>services</folder>
<folder>language</folder>
<folder>sql</folder>
</files>
<languages folder="language">
<language tag="en-GB">en-GB/plg_system_mokosuitelibrary.ini</language>
<language tag="en-GB">en-GB/plg_system_mokosuitelibrary.sys.ini</language>
</languages>
<install><sql><file driver="mysql" charset="utf8">sql/install.mysql.sql</file></sql></install>
<uninstall><sql><file driver="mysql" charset="utf8">sql/uninstall.mysql.sql</file></sql></uninstall>
<config>
<fields name="params">
<fieldset name="basic" label="Lending Defaults">
<field name="loan_period_days" type="number" default="14" label="Default Loan Period (days)" />
<field name="max_renewals" type="number" default="2" label="Max Renewals" />
<field name="grace_period_days" type="number" default="1" label="Grace Period (days)" />
<field name="fine_per_day" type="number" default="0.25" label="Fine Per Day ($)" step="0.05" />
<field name="max_items_per_patron" type="number" default="10" label="Max Items Per Patron" />
<field name="reservation_hold_days" type="number" default="3" label="Reservation Hold (days)" />
</fieldset>
</fields>
</config>
</extension>
@@ -0,0 +1,27 @@
<?php
defined('_JEXEC') or die;
use Joomla\CMS\Extension\PluginInterface;
use Joomla\CMS\Factory;
use Joomla\CMS\Plugin\PluginHelper;
use Joomla\DI\Container;
use Joomla\DI\ServiceProviderInterface;
use Joomla\Event\DispatcherInterface;
use Moko\Plugin\System\MokoSuiteLibrary\Extension\Library;
return new class implements ServiceProviderInterface
{
public function register(Container $container): void
{
$container->set(
PluginInterface::class,
function (Container $container) {
$dispatcher = $container->get(DispatcherInterface::class);
$plugin = new Library($dispatcher, (array) PluginHelper::getPlugin('system', 'mokosuitelibrary'));
$plugin->setApplication(Factory::getApplication());
return $plugin;
}
);
}
};
@@ -0,0 +1,122 @@
--
-- MokoSuite Library Tables
--
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_items` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`title` VARCHAR(255) NOT NULL,
`subtitle` VARCHAR(255) NOT NULL DEFAULT '',
`isbn` VARCHAR(20) NOT NULL DEFAULT '',
`barcode` VARCHAR(50) NOT NULL DEFAULT '',
`item_type` ENUM('book','dvd','equipment','tool','game','periodical','digital') NOT NULL DEFAULT 'book',
`category` VARCHAR(100) NOT NULL DEFAULT '',
`author` VARCHAR(255) NOT NULL DEFAULT '',
`publisher` VARCHAR(255) NOT NULL DEFAULT '',
`publish_year` SMALLINT UNSIGNED DEFAULT NULL,
`description` TEXT,
`cover_image` VARCHAR(500) NOT NULL DEFAULT '',
`total_copies` INT UNSIGNED NOT NULL DEFAULT 1,
`available_copies` INT UNSIGNED NOT NULL DEFAULT 1,
`published` TINYINT NOT NULL DEFAULT 1,
`created` DATETIME NOT NULL,
`modified` DATETIME DEFAULT NULL,
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_isbn` (`isbn`),
KEY `idx_barcode` (`barcode`),
KEY `idx_type` (`item_type`),
KEY `idx_category` (`category`),
KEY `idx_author` (`author`(100)),
FULLTEXT `ft_search` (`title`, `subtitle`, `author`, `description`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_copies` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`item_id` INT UNSIGNED NOT NULL,
`copy_number` INT UNSIGNED NOT NULL DEFAULT 1,
`barcode` VARCHAR(50) NOT NULL DEFAULT '',
`condition_grade` ENUM('new','good','fair','poor','withdrawn') NOT NULL DEFAULT 'good',
`location` VARCHAR(100) NOT NULL DEFAULT '',
`status` ENUM('available','checked_out','on_hold','in_repair','lost','withdrawn') NOT NULL DEFAULT 'available',
`notes` TEXT,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_barcode` (`barcode`),
KEY `idx_item` (`item_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_patrons` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`contact_id` INT DEFAULT NULL,
`card_number` VARCHAR(50) NOT NULL DEFAULT '',
`name` VARCHAR(255) NOT NULL,
`email` VARCHAR(255) NOT NULL DEFAULT '',
`phone` VARCHAR(50) NOT NULL DEFAULT '',
`max_items` INT UNSIGNED NOT NULL DEFAULT 10,
`status` ENUM('active','suspended','expired') NOT NULL DEFAULT 'active',
`suspended_reason` VARCHAR(255) NOT NULL DEFAULT '',
`membership_expires` DATE DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `idx_card` (`card_number`),
KEY `idx_contact` (`contact_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_checkouts` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`copy_id` INT UNSIGNED NOT NULL,
`patron_id` INT UNSIGNED NOT NULL,
`checked_out_at` DATETIME NOT NULL,
`due_date` DATE NOT NULL,
`returned_at` DATETIME DEFAULT NULL,
`renewals` INT UNSIGNED NOT NULL DEFAULT 0,
`status` ENUM('active','returned','overdue','lost') NOT NULL DEFAULT 'active',
`created_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_copy` (`copy_id`),
KEY `idx_patron` (`patron_id`),
KEY `idx_due` (`due_date`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_reservations` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`item_id` INT UNSIGNED NOT NULL,
`patron_id` INT UNSIGNED NOT NULL,
`position` INT UNSIGNED NOT NULL DEFAULT 1,
`status` ENUM('waiting','ready','fulfilled','cancelled','expired') NOT NULL DEFAULT 'waiting',
`notified_at` DATETIME DEFAULT NULL,
`expires_at` DATETIME DEFAULT NULL,
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_item` (`item_id`, `position`),
KEY `idx_patron` (`patron_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fines` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`patron_id` INT UNSIGNED NOT NULL,
`checkout_id` INT UNSIGNED DEFAULT NULL,
`amount` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`paid` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`reason` VARCHAR(255) NOT NULL DEFAULT 'overdue',
`status` ENUM('outstanding','paid','waived') NOT NULL DEFAULT 'outstanding',
`waived_reason` VARCHAR(255) NOT NULL DEFAULT '',
`created` DATETIME NOT NULL,
PRIMARY KEY (`id`),
KEY `idx_patron` (`patron_id`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS `#__mokosuitelibrary_fine_payments` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`fine_id` INT UNSIGNED NOT NULL,
`amount` DECIMAL(10,2) NOT NULL,
`method` ENUM('cash','card','online','waiver') NOT NULL DEFAULT 'cash',
`paid_at` DATETIME NOT NULL,
`received_by` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_fine` (`fine_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -0,0 +1,11 @@
--
-- MokoSuite Library — Uninstall
--
DROP TABLE IF EXISTS `#__mokosuitelibrary_fine_payments`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_fines`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_reservations`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_checkouts`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_copies`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_patrons`;
DROP TABLE IF EXISTS `#__mokosuitelibrary_items`;
@@ -0,0 +1,18 @@
<?php
namespace Moko\Plugin\System\MokoSuiteLibrary\Extension;
defined('_JEXEC') or die;
use Joomla\CMS\Plugin\CMSPlugin;
use Joomla\Event\SubscriberInterface;
class Library extends CMSPlugin implements SubscriberInterface
{
protected $autoloadLanguage = true;
public static function getSubscribedEvents(): array
{
return [];
}
}
@@ -0,0 +1,86 @@
<?php
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Library catalog — search, item CRUD, availability tracking.
*/
class CatalogHelper
{
public static function search(string $query = '', array $filters = [], int $limit = 20, int $offset = 0): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$q = $db->getQuery(true)
->select('i.*, i.available_copies > 0 AS is_available')
->from($db->quoteName('#__mokosuitelibrary_items', 'i'))
->where($db->quoteName('i.published') . ' = 1')
->order('i.title ASC');
if (!empty($query)) {
$q->where('MATCH(i.title, i.subtitle, i.author, i.description) AGAINST (' . $db->quote($query) . ' IN BOOLEAN MODE)');
}
if (!empty($filters['item_type'])) {
$q->where($db->quoteName('i.item_type') . ' = ' . $db->quote($filters['item_type']));
}
if (!empty($filters['category'])) {
$q->where($db->quoteName('i.category') . ' = ' . $db->quote($filters['category']));
}
if (isset($filters['available_only']) && $filters['available_only']) {
$q->where($db->quoteName('i.available_copies') . ' > 0');
}
$db->setQuery($q, $offset, $limit);
return $db->loadAssocList() ?: [];
}
public static function getItem(int $itemId): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('i.*')
->from($db->quoteName('#__mokosuitelibrary_items', 'i'))
->where($db->quoteName('i.id') . ' = ' . (int) $itemId);
$db->setQuery($query);
$item = $db->loadObject();
if ($item) {
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_copies'))
->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
->order('copy_number ASC');
$db->setQuery($query);
$item->copies = $db->loadObjectList() ?: [];
}
return $item;
}
public static function getAvailableCopies(int $itemId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_copies'))
->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
->where($db->quoteName('status') . ' = ' . $db->quote('available'));
$db->setQuery($query);
return $db->loadObjectList() ?: [];
}
}
@@ -0,0 +1,211 @@
<?php
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Library checkout/return — lending transactions, renewals, overdue detection.
*/
class CheckoutHelper
{
public static function checkout(int $copyId, int $patronId, int $loanDays = 14): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$dueDate = Factory::getDate('+' . $loanDays . ' days')->format('Y-m-d');
$userId = Factory::getApplication()->getIdentity()->id;
$db->transactionStart();
try {
// Lock copy row to prevent double-checkout
$query = $db->getQuery(true)
->select('status')
->from($db->quoteName('#__mokosuitelibrary_copies'))
->where($db->quoteName('id') . ' = ' . (int) $copyId)
->forUpdate();
$db->setQuery($query);
$status = $db->loadResult();
if ($status !== 'available') {
throw new \RuntimeException('Copy is not available for checkout');
}
// Create checkout record
$checkout = (object) [
'copy_id' => (int) $copyId,
'patron_id' => (int) $patronId,
'checked_out_at' => $now,
'due_date' => $dueDate,
'status' => 'active',
'created_by' => $userId,
];
$db->insertObject('#__mokosuitelibrary_checkouts', $checkout, 'id');
// Update copy status
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_copies'))
->set($db->quoteName('status') . ' = ' . $db->quote('checked_out'))
->where($db->quoteName('id') . ' = ' . (int) $copyId);
$db->setQuery($query);
$db->execute();
// Decrement available count on item
$query = $db->getQuery(true)
->select('item_id')
->from($db->quoteName('#__mokosuitelibrary_copies'))
->where($db->quoteName('id') . ' = ' . (int) $copyId);
$db->setQuery($query);
$itemId = $db->loadResult();
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_items'))
->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' - 1')
->where($db->quoteName('id') . ' = ' . (int) $itemId)
->where($db->quoteName('available_copies') . ' > 0');
$db->setQuery($query);
$db->execute();
$db->transactionCommit();
return $checkout;
} catch (\Exception $e) {
$db->transactionRollback();
throw $e;
}
}
public static function checkin(int $copyId): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$db->transactionStart();
try {
// Find active checkout for this copy
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_checkouts'))
->where($db->quoteName('copy_id') . ' = ' . (int) $copyId)
->where($db->quoteName('status') . ' IN (' . $db->quote('active') . ', ' . $db->quote('overdue') . ')')
->forUpdate();
$db->setQuery($query);
$checkout = $db->loadObject();
if (!$checkout) {
$db->transactionRollback();
return null;
}
// Mark returned
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_checkouts'))
->set($db->quoteName('returned_at') . ' = ' . $db->quote($now))
->set($db->quoteName('status') . ' = ' . $db->quote('returned'))
->where($db->quoteName('id') . ' = ' . (int) $checkout->id);
$db->setQuery($query);
$db->execute();
// Update copy status
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_copies'))
->set($db->quoteName('status') . ' = ' . $db->quote('available'))
->where($db->quoteName('id') . ' = ' . (int) $copyId);
$db->setQuery($query);
$db->execute();
// Increment available count
$query = $db->getQuery(true)
->select('item_id')
->from($db->quoteName('#__mokosuitelibrary_copies'))
->where($db->quoteName('id') . ' = ' . (int) $copyId);
$db->setQuery($query);
$itemId = $db->loadResult();
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_items'))
->set($db->quoteName('available_copies') . ' = ' . $db->quoteName('available_copies') . ' + 1')
->where($db->quoteName('id') . ' = ' . (int) $itemId);
$db->setQuery($query);
$db->execute();
// Calculate fine if overdue
if ($checkout->due_date < Factory::getDate()->format('Y-m-d')) {
FineHelper::calculate((int) $checkout->id);
}
$db->transactionCommit();
return $checkout;
} catch (\Exception $e) {
$db->transactionRollback();
throw $e;
}
}
public static function renew(int $checkoutId, int $additionalDays = 14): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_checkouts'))
->where($db->quoteName('id') . ' = ' . (int) $checkoutId)
->where($db->quoteName('status') . ' = ' . $db->quote('active'));
$db->setQuery($query);
$checkout = $db->loadObject();
if (!$checkout || $checkout->renewals >= 2) {
return false;
}
$newDue = Factory::getDate($checkout->due_date . ' +' . $additionalDays . ' days')->format('Y-m-d');
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_checkouts'))
->set($db->quoteName('due_date') . ' = ' . $db->quote($newDue))
->set($db->quoteName('renewals') . ' = ' . $db->quoteName('renewals') . ' + 1')
->where($db->quoteName('id') . ' = ' . (int) $checkoutId);
$db->setQuery($query);
$db->execute();
return true;
}
public static function getOverdue(): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$today = Factory::getDate()->format('Y-m-d');
$query = $db->getQuery(true)
->select('c.*, p.name AS patron_name, p.email AS patron_email, i.title AS item_title')
->from($db->quoteName('#__mokosuitelibrary_checkouts', 'c'))
->join('INNER', $db->quoteName('#__mokosuitelibrary_copies', 'cp') . ' ON cp.id = c.copy_id')
->join('INNER', $db->quoteName('#__mokosuitelibrary_items', 'i') . ' ON i.id = cp.item_id')
->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = c.patron_id')
->where($db->quoteName('c.status') . ' = ' . $db->quote('active'))
->where($db->quoteName('c.due_date') . ' < ' . $db->quote($today))
->order('c.due_date ASC');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
}
@@ -0,0 +1,135 @@
<?php
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Library fines — overdue calculation, payments, waivers.
*/
class FineHelper
{
public static function calculate(int $checkoutId, float $ratePerDay = 0.25): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_checkouts'))
->where($db->quoteName('id') . ' = ' . (int) $checkoutId);
$db->setQuery($query);
$checkout = $db->loadObject();
if (!$checkout) {
return null;
}
$dueDate = new \DateTime($checkout->due_date);
$returnDate = $checkout->returned_at
? new \DateTime($checkout->returned_at)
: new \DateTime();
$daysOverdue = max(0, (int) $dueDate->diff($returnDate)->days);
if ($daysOverdue <= 0) {
return null;
}
$amount = round($daysOverdue * $ratePerDay, 2);
$fine = (object) [
'patron_id' => (int) $checkout->patron_id,
'checkout_id' => (int) $checkoutId,
'amount' => $amount,
'reason' => 'overdue',
'status' => 'outstanding',
'created' => Factory::getDate()->toSql(),
];
$db->insertObject('#__mokosuitelibrary_fines', $fine, 'id');
return $fine;
}
public static function getPatronBalance(int $patronId): float
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('COALESCE(SUM(amount - paid), 0)')
->from($db->quoteName('#__mokosuitelibrary_fines'))
->where($db->quoteName('patron_id') . ' = ' . (int) $patronId)
->where($db->quoteName('status') . ' = ' . $db->quote('outstanding'));
$db->setQuery($query);
return (float) $db->loadResult();
}
public static function recordPayment(int $fineId, float $amount, string $method = 'cash'): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$userId = Factory::getApplication()->getIdentity()->id;
$payment = (object) [
'fine_id' => (int) $fineId,
'amount' => $amount,
'method' => $method,
'paid_at' => $now,
'received_by' => $userId,
];
$db->insertObject('#__mokosuitelibrary_fine_payments', $payment, 'id');
// Update paid amount on fine
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_fines'))
->set($db->quoteName('paid') . ' = ' . $db->quoteName('paid') . ' + ' . (float) $amount)
->where($db->quoteName('id') . ' = ' . (int) $fineId);
$db->setQuery($query);
$db->execute();
// Check if fully paid
$query = $db->getQuery(true)
->select('amount, paid')
->from($db->quoteName('#__mokosuitelibrary_fines'))
->where($db->quoteName('id') . ' = ' . (int) $fineId);
$db->setQuery($query);
$fine = $db->loadObject();
if ($fine && $fine->paid >= $fine->amount) {
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_fines'))
->set($db->quoteName('status') . ' = ' . $db->quote('paid'))
->where($db->quoteName('id') . ' = ' . (int) $fineId);
$db->setQuery($query);
$db->execute();
}
return true;
}
public static function waive(int $fineId, string $reason): bool
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_fines'))
->set($db->quoteName('status') . ' = ' . $db->quote('waived'))
->set($db->quoteName('waived_reason') . ' = ' . $db->quote($reason))
->where($db->quoteName('id') . ' = ' . (int) $fineId);
$db->setQuery($query);
$db->execute();
return true;
}
}
@@ -0,0 +1,93 @@
<?php
namespace Moko\Plugin\System\MokoSuiteLibrary\Helper;
defined('_JEXEC') or die;
use Joomla\CMS\Factory;
use Joomla\Database\DatabaseInterface;
/**
* Library reservations — holds, queue management, notification on availability.
*/
class ReservationHelper
{
public static function reserve(int $itemId, int $patronId): object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
// Get next position in queue
$query = $db->getQuery(true)
->select('COALESCE(MAX(position), 0) + 1')
->from($db->quoteName('#__mokosuitelibrary_reservations'))
->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
->where($db->quoteName('status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')');
$db->setQuery($query);
$position = (int) $db->loadResult();
$reservation = (object) [
'item_id' => (int) $itemId,
'patron_id' => (int) $patronId,
'position' => $position,
'status' => 'waiting',
'created' => $now,
];
$db->insertObject('#__mokosuitelibrary_reservations', $reservation, 'id');
return $reservation;
}
public static function fulfillNext(int $itemId, int $holdDays = 3): ?object
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$now = Factory::getDate()->toSql();
$expires = Factory::getDate('+' . $holdDays . ' days')->toSql();
$query = $db->getQuery(true)
->select('*')
->from($db->quoteName('#__mokosuitelibrary_reservations'))
->where($db->quoteName('item_id') . ' = ' . (int) $itemId)
->where($db->quoteName('status') . ' = ' . $db->quote('waiting'))
->order('position ASC')
->setLimit(1);
$db->setQuery($query);
$reservation = $db->loadObject();
if (!$reservation) {
return null;
}
$query = $db->getQuery(true)
->update($db->quoteName('#__mokosuitelibrary_reservations'))
->set($db->quoteName('status') . ' = ' . $db->quote('ready'))
->set($db->quoteName('notified_at') . ' = ' . $db->quote($now))
->set($db->quoteName('expires_at') . ' = ' . $db->quote($expires))
->where($db->quoteName('id') . ' = ' . (int) $reservation->id);
$db->setQuery($query);
$db->execute();
return $reservation;
}
public static function getQueue(int $itemId): array
{
$db = Factory::getContainer()->get(DatabaseInterface::class);
$query = $db->getQuery(true)
->select('r.*, p.name AS patron_name')
->from($db->quoteName('#__mokosuitelibrary_reservations', 'r'))
->join('INNER', $db->quoteName('#__mokosuitelibrary_patrons', 'p') . ' ON p.id = r.patron_id')
->where($db->quoteName('r.item_id') . ' = ' . (int) $itemId)
->where($db->quoteName('r.status') . ' IN (' . $db->quote('waiting') . ', ' . $db->quote('ready') . ')')
->order('r.position ASC');
$db->setQuery($query);
return $db->loadAssocList() ?: [];
}
}
+22
View File
@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<extension type="package" method="upgrade">
<name>Package - MokoSuite Library</name>
<packagename>mokosuitelibrary</packagename>
<version>01.00.00</version>
<creationDate>2026-06-23</creationDate>
<author>Moko Consulting</author>
<authorEmail>hello@mokoconsulting.tech</authorEmail>
<authorUrl>https://mokoconsulting.tech</authorUrl>
<copyright>Copyright (C) 2026 Moko Consulting. All rights reserved.</copyright>
<license>GNU General Public License version 3 or later; see LICENSE</license>
<description>Library and resource lending management</description>
<php_minimum>8.3</php_minimum>
<dlid prefix="dlid=" suffix=""/>
<blockChildUninstall>true</blockChildUninstall>
<files folder="packages">
<file type="plugin" id="plg_system_mokosuitelibrary" group="system">plg_system_mokosuitelibrary.zip</file>
</files>
<updateservers>
<server type="extension" priority="1" name="Package - MokoSuite Library">https://git.mokoconsulting.tech/MokoConsulting/MokoSuiteLibrary/updates.xml</server>
</updateservers>
</extension>