Add tick delete function (#64)

Reviewed-on: https://gitea.subcultureofone.org/greg/tkr/pulls/64
Co-authored-by: Greg Sarjeant <greg@subcultureofone.org>
Co-committed-by: Greg Sarjeant <greg@subcultureofone.org>
This commit is contained in:
Greg Sarjeant 2025-08-09 14:49:56 +00:00 committed by greg
parent 16f8631fd4
commit 195de75b78
8 changed files with 172 additions and 4 deletions

View File

@ -376,6 +376,20 @@ time {
text-decoration: none;
}
.delete-tick-form {
display: inline;
margin: 0;
}
.delete-tick-button {
background: none;
border: none;
padding: 0;
margin: 0;
cursor: pointer;
font-size: inherit;
}
/* Mood selection page */
.mood-option input {
position: absolute;

View File

@ -27,4 +27,35 @@ class TickController extends Controller{
echo '<h1>500 - Internal Server Error</h1>';
}
}
public function handleDelete(string $id){
global $app;
$id = (int) $id;
Log::debug("Attempting to delete tick with ID: {$id}");
try {
$tickModel = new TickModel($app['db'], $app['settings']);
// TickModel->delete() handles validation and sets flash messages:
// - "Tick not found" if tick doesn't exist
// - "Tick is too old to delete" if outside deletion window
// - "Deleted: '{content}'" on success
$success = $tickModel->delete($id);
if ($success) {
Log::info("Successfully deleted tick {$id}");
} else {
Log::warning("Failed to delete tick {$id}");
}
} catch (Exception $e) {
Log::error("Exception while deleting tick {$id}: " . $e->getMessage());
Session::setFlashMessage('error', 'An error occurred while deleting the tick');
}
// Redirect back to homepage
header('Location: ' . Util::buildRelativeUrl($app['settings']->basePath, ''));
exit();
}
}

View File

@ -25,6 +25,7 @@ class Router {
['tkr-setup', 'AdminController@showSetup'],
['tkr-setup', 'AdminController@handleSetup', ['POST']],
['tick/{id}', 'TickController'],
['tick/{id}/delete', 'TickController@handleDelete', ['POST']],
['css/custom/{filename}.css', 'CssController@serveCustomCss'],
];

View File

@ -12,6 +12,8 @@ class SettingsModel {
public ?int $cssId = null;
public bool $strictAccessibility = true;
public ?int $logLevel = null;
// not currently configurable
public int $tickDeleteHours = 1;
public function __construct(private PDO $db) {}

View File

@ -7,8 +7,17 @@ class TickModel {
public function getPage(int $limit, int $offset = 0): array {
$stmt = $this->db->prepare("SELECT id, timestamp, tick FROM tick ORDER BY timestamp DESC LIMIT ? OFFSET ?");
$stmt->execute([$limit, $offset]);
return $stmt->fetchAll(PDO::FETCH_ASSOC);
$ticks = $stmt->fetchAll(PDO::FETCH_ASSOC);
return array_map(function($tick) {
$tickTime = new DateTimeImmutable($tick['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
$tick['can_delete'] = $hoursSinceCreation <= $this->settings->tickDeleteHours;
return $tick;
}, $ticks);
}
public function insert(string $tick, ?DateTimeImmutable $datetime = null): void {
@ -35,4 +44,33 @@ class TickModel {
'settings' => $this->settings,
];
}
public function delete(int $id): bool {
// Get tick and validate
$stmt = $this->db->prepare("SELECT tick, timestamp FROM tick WHERE id=?");
$stmt->execute([$id]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row === false || empty($row)) {
Session::setFlashMessage('error', 'Tick not found');
return false;
}
// Check deletion window
$tickTime = new DateTimeImmutable($row['timestamp'], new DateTimeZone('UTC'));
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
$hoursSinceCreation = ($now->getTimestamp() - $tickTime->getTimestamp()) / 3600;
if ($hoursSinceCreation > $this->settings->tickDeleteHours) {
Session::setFlashMessage('error', 'Tick is too old to delete');
return false;
}
// Delete and set success message
$stmt = $this->db->prepare("DELETE FROM tick WHERE id=?");
$stmt->execute([$id]);
Session::setFlashMessage('success', "Deleted: '{$row['tick']}'");
return true;
}
}

View File

@ -23,7 +23,15 @@ class TicksView {
$relativeTime = Util::relative_time($tick['timestamp']);
?>
<li class="tick" tabindex="0">
🗑️ <time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
<?php if ($tick['can_delete']): ?>
<form method="post"
action="<?= Util::buildRelativeUrl($settings->basePath, "tick/{$tick['id']}/delete") ?>"
class="delete-tick-form">
<input type="hidden" name="csrf_token" value="<?= Util::escape_html($_SESSION['csrf_token']) ?>">
<button type="submit" class="delete-tick-button">🗑️</button>
</form>
<?php endif ?>
<time datetime="<?php echo $datetime->format('c') ?>"><?php echo Util::escape_html($relativeTime) ?></time>
<span class="tick-text"><?php echo Util::linkify(Util::escape_html($tick['tick'])) ?></span>
</li>
<?php endforeach; ?>

View File

@ -152,5 +152,4 @@ class TickControllerTest extends TestCase
// Should return 500 error
$this->assertStringContainsString('500 - Internal Server Error', $output);
}
}

View File

@ -0,0 +1,75 @@
<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;
class TickModelTest extends TestCase
{
private $mockPdo;
private $settings;
protected function setUp(): void
{
$this->mockPdo = $this->createMock(PDO::class);
$this->settings = new SettingsModel($this->mockPdo);
$this->settings->tickDeleteHours = 1; // 1 hour deletion window
}
public function testDeleteWithRecentTick(): void
{
// Mock successful deletion of recent tick
$recentTimestamp = (new DateTimeImmutable())->format('Y-m-d H:i:s');
$mockStatement1 = $this->createMock(PDOStatement::class);
$mockStatement1->method('execute')->with([123]);
$mockStatement1->method('fetch')->willReturn([
'tick' => 'Test content',
'timestamp' => $recentTimestamp
]);
$mockStatement2 = $this->createMock(PDOStatement::class);
$mockStatement2->method('execute')->with([123]);
$this->mockPdo->method('prepare')
->willReturnOnConsecutiveCalls($mockStatement1, $mockStatement2);
$tickModel = new TickModel($this->mockPdo, $this->settings);
$result = $tickModel->delete(123);
$this->assertTrue($result);
}
public function testDeleteWithNonexistentTick(): void
{
$mockStatement = $this->createMock(PDOStatement::class);
$mockStatement->method('execute')->with([999]);
$mockStatement->method('fetch')->willReturn(false);
$this->mockPdo->method('prepare')->willReturn($mockStatement);
$tickModel = new TickModel($this->mockPdo, $this->settings);
$result = $tickModel->delete(999);
$this->assertFalse($result);
}
public function testDeleteWithOldTick(): void
{
// Tick from 3 hours ago (outside 1-hour window)
$oldTimestamp = (new DateTimeImmutable('-3 hours'))->format('Y-m-d H:i:s');
$mockStatement = $this->createMock(PDOStatement::class);
$mockStatement->method('execute')->with([456]);
$mockStatement->method('fetch')->willReturn([
'tick' => 'Old content',
'timestamp' => $oldTimestamp
]);
$this->mockPdo->method('prepare')->willReturn($mockStatement);
$tickModel = new TickModel($this->mockPdo, $this->settings);
$result = $tickModel->delete(456);
$this->assertFalse($result);
}
}