Skip to content

Commit 4140eef

Browse files
committed
feat: Add automatic award system for Easter Egg GeoKrety discoveries
Implements comprehensive automatic award assignment when users make "visited" moves on Easter Egg type GeoKrety. Features: - Generic AutomaticPrizeAwarder orchestrator for extensible award services - EasterEggAwardService for Hidden GeoKrety Finder award logic - Database migration for Hidden GeoKrety Finder award (ID 58) - Event-driven architecture with audit trail integration - Comprehensive pgtap tests with 10 test cases - Error handling that doesn't break move processing - 1-hour cache TTL for award lookups using F3 Cortex ORM
1 parent 52dd439 commit 4140eef

File tree

6 files changed

+209
-0
lines changed

6 files changed

+209
-0
lines changed
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace GeoKrety\Service\Award;
4+
5+
use GeoKrety\Model\Move;
6+
7+
class AutomaticPrizeAwarder {
8+
/**
9+
* Array of award service classes to process.
10+
*/
11+
private array $awardServices = [
12+
EasterEggAwardService::class,
13+
// Future award services can be added here
14+
];
15+
16+
/**
17+
* Process all automatic awards for a given move.
18+
*/
19+
public static function processMove(Move $move): void {
20+
$awarder = new self();
21+
$awarder->handleMove($move);
22+
}
23+
24+
/**
25+
* Handle move processing by iterating through all award services.
26+
*/
27+
private function handleMove(Move $move): void {
28+
// Only process moves with an author (logged-in users)
29+
if ($move->author === null) {
30+
return;
31+
}
32+
33+
foreach ($this->awardServices as $serviceClass) {
34+
try {
35+
$service = new $serviceClass();
36+
37+
if ($service instanceof AwardServiceInterface) {
38+
if ($service->shouldAward($move)) {
39+
$service->awardUser($move);
40+
}
41+
}
42+
} catch (\Exception $e) {
43+
// Log error but don't break move processing
44+
// Awards are nice-to-have, not critical functionality
45+
error_log("Award service error for {$serviceClass}: ".$e->getMessage());
46+
}
47+
}
48+
}
49+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<?php
2+
3+
namespace GeoKrety\Service\Award;
4+
5+
use GeoKrety\Model\Move;
6+
7+
interface AwardServiceInterface {
8+
/**
9+
* Determine if this move should trigger an award for the user.
10+
*/
11+
public function shouldAward(Move $move): bool;
12+
13+
/**
14+
* Award the user for this move.
15+
*/
16+
public function awardUser(Move $move): void;
17+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
<?php
2+
3+
namespace GeoKrety\Service\Award;
4+
5+
use GeoKrety\GeokretyType;
6+
use GeoKrety\LogType;
7+
use GeoKrety\Model\Awards;
8+
use GeoKrety\Model\AwardsWon;
9+
use GeoKrety\Model\Move;
10+
11+
class EasterEggAwardService implements AwardServiceInterface {
12+
private const AWARD_NAME = 'Hidden GeoKrety Finder';
13+
14+
/**
15+
* Check if this move should trigger the Easter Egg award.
16+
*/
17+
public function shouldAward(Move $move): bool {
18+
return $move->author !== null
19+
&& $move->geokret !== null
20+
&& $move->geokret->type->getTypeId() === GeokretyType::GEOKRETY_TYPE_EASTER_EGG
21+
&& $move->move_type->getLogTypeId() === LogType::LOG_TYPE_SEEN;
22+
}
23+
24+
/**
25+
* Award the Hidden GeoKrety Finder award to the user.
26+
*/
27+
public function awardUser(Move $move): void {
28+
// Load award with cache (1 hour TTL)
29+
$award = new Awards();
30+
$award->load(['name = ?', self::AWARD_NAME], ttl: 3600);
31+
32+
if ($award->dry()) {
33+
// Award doesn't exist - log and return
34+
error_log("Award '{self::AWARD_NAME}' not found in database");
35+
36+
return;
37+
}
38+
39+
// Create award assignment
40+
$awardWon = new AwardsWon();
41+
$awardWon->holder = $move->author->id;
42+
$awardWon->award = $award->id;
43+
$awardWon->description = 'Automatically awarded for discovering a Hidden GeoKrety';
44+
45+
try {
46+
$awardWon->save();
47+
48+
// Fire event for audit trail
49+
$events = \Sugar\Event::instance();
50+
$events->emit('award.given', [
51+
'award_id' => $award->id,
52+
'award_name' => self::AWARD_NAME,
53+
'user_id' => $move->author->id,
54+
'move_id' => $move->id,
55+
'geokret_id' => $move->geokret->id,
56+
'automatic' => true,
57+
]);
58+
} catch (\Exception $e) {
59+
// Database constraint violation (duplicate award) - silently ignore
60+
// Other errors are also ignored to not break move processing
61+
// This is logged in AutomaticPrizeAwarder
62+
}
63+
}
64+
}

website/app/events.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,10 @@ function audit(string $event, $newObjectModel) {
305305
if (!is_null($move->author) && !is_null($move->geokret->owner) && $move->geokret->owner === $move->author) {
306306
GeoKrety\Service\UserBanner::generate($move->author);
307307
}
308+
309+
// Process automatic awards
310+
GeoKrety\Service\Award\AutomaticPrizeAwarder::processMove($move);
311+
308312
audit('move.created', $move);
309313
Metrics::counter('move_created_total', 'Total number of move created');
310314
});
@@ -328,6 +332,11 @@ function audit(string $event, $newObjectModel) {
328332
audit('move.deleted', $move);
329333
Metrics::counter('move_deleted_total', 'Total number of move deleted');
330334
});
335+
336+
// Award events
337+
$events->on('award.given', function (array $context) {
338+
audit('award.given', $context);
339+
});
331340
$events->on('move-comment.created', function (GeoKrety\Model\MoveComment $comment) {
332341
audit('move-comment.created', $comment);
333342
Metrics::counter('move_comment_created_total', 'Total number of move comment created');
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Phinx\Migration\AbstractMigration;
6+
7+
final class AddHiddenGeokretyFinderAward extends AbstractMigration {
8+
public function change(): void {
9+
$this->execute("
10+
INSERT INTO gk_awards (name, created_on_datetime, updated_on_datetime, description, filename, type)
11+
VALUES ('Hidden GeoKrety Finder', NOW(), NOW(), 'Has discovered one Hidden GeoKrety', 'hidden-finder.svg', 'manual')
12+
");
13+
}
14+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
-- Start transaction and plan the tests.
2+
BEGIN;
3+
4+
SELECT plan(9);
5+
6+
-- Setup test data starting at ID 1 (following convention)
7+
INSERT INTO "gk_users" ("id", "username", "registration_ip") VALUES (1, 'test 1', '127.0.0.1');
8+
INSERT INTO "gk_users" ("id", "username", "registration_ip") VALUES (2, 'test 2', '127.0.0.1');
9+
INSERT INTO "gk_users" ("id", "username", "registration_ip") VALUES (3, 'test 3', '127.0.0.1');
10+
11+
-- Test awards
12+
INSERT INTO "gk_awards" ("id", "name", "description", "filename", "type") VALUES (1, 'Test Award', 'Test award description', 'test.svg', 'manual');
13+
INSERT INTO "gk_awards" ("id", "name", "description", "filename", "type") VALUES (2, 'Hidden GeoKrety Finder', 'Has discovered one Hidden GeoKrety', 'hidden-finder.svg', 'manual');
14+
INSERT INTO "gk_awards" ("id", "name", "description", "filename", "type") VALUES (3, 'Another Award', 'Another test award', 'another.svg', 'manual');
15+
16+
-- Test basic award assignment works
17+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (1, 1, 'Test award assignment')$$, 'Basic award assignment should work');
18+
19+
-- Test unique constraint (holder, award) - user cannot get same award twice
20+
SELECT throws_ok(
21+
$$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (1, 1, 'Duplicate award')$$,
22+
23505,
23+
'duplicate key value violates unique constraint "gk_awards_won_holder_award"',
24+
'Should prevent duplicate awards to same user'
25+
);
26+
27+
-- Test same award can be given to different users
28+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (2, 1, 'Same award to different user')$$, 'Same award can be given to different users');
29+
30+
-- Test different awards can be given to same user
31+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (1, 3, 'Different award to same user')$$, 'Different awards can be given to same user');
32+
33+
-- Test Easter Egg GeoKrety setup (type 10)
34+
INSERT INTO "gk_geokrety" ("id", "name", "type", "owner", "holder", "created_on_datetime") VALUES (1, 'Easter Egg Test', 10, 1, 1, '2024-07-21 12:15:00+00');
35+
INSERT INTO "gk_geokrety" ("id", "name", "type", "owner", "holder", "created_on_datetime") VALUES (2, 'Regular GeoKret', 0, 1, 1, '2024-07-21 12:15:00+00');
36+
INSERT INTO "gk_geokrety" ("id", "name", "type", "owner", "holder", "created_on_datetime") VALUES (3, 'Book GeoKret', 1, 2, 2, '2024-07-21 12:15:00+00');
37+
INSERT INTO "gk_geokrety" ("id", "name", "type", "owner", "holder", "created_on_datetime") VALUES (4, 'Another Easter Egg', 10, 2, 2, '2024-07-21 12:15:00+00');
38+
39+
-- Test award assignment can happen to different users for Hidden GeoKrety Finder
40+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (1, 2, 'Found Easter Egg')$$, 'User 1 can get Hidden GeoKrety Finder award');
41+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (2, 2, 'Found Easter Egg')$$, 'User 2 can get Hidden GeoKrety Finder award');
42+
SELECT lives_ok($$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (3, 2, 'Found Easter Egg')$$, 'User 3 can get Hidden GeoKrety Finder award');
43+
44+
-- Test same user cannot get Hidden GeoKrety Finder award twice
45+
SELECT throws_ok(
46+
$$INSERT INTO "gk_awards_won" ("holder", "award", "description") VALUES (1, 2, 'Duplicate Easter Egg award')$$,
47+
23505,
48+
'duplicate key value violates unique constraint "gk_awards_won_holder_award"',
49+
'Should prevent duplicate Hidden GeoKrety Finder awards to same user'
50+
);
51+
52+
-- Test award lookup by name functionality
53+
SELECT ok(EXISTS(SELECT 1 FROM gk_awards WHERE name = 'Hidden GeoKrety Finder'), 'Hidden GeoKrety Finder award exists in database');
54+
55+
SELECT * FROM finish();
56+
ROLLBACK;

0 commit comments

Comments
 (0)