Files
Mitgliederverwaltung/lib/Service/CalendarSyncService.php
T

495 lines
17 KiB
PHP

<?php
declare(strict_types=1);
namespace OCA\Mitgliederverwaltung\Service;
use OCA\Mitgliederverwaltung\Db\Member;
use OCA\Mitgliederverwaltung\Db\MemberMapper;
use OCP\DB\QueryBuilder\IQueryBuilder;
use OCP\IDBConnection;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;
use Sabre\VObject\Component\VCalendar;
/**
* Syncs member birthday events to a dedicated Nextcloud calendar via CalDAV.
*
* Creates/manages the "Pfadfinder Geburtstage" calendar with recurring yearly
* all-day events for each active member (excluding Erziehungsberechtigte).
*
* Note: Uses a custom staging table (mv_calendar_events) because
* OCP\Calendar\IManager does not provide write methods. All sync
* operations are guarded by PermissionService to enforce Nextcloud
* access control. See Issue #170.
*
* Part of Issue #50.
*/
class CalendarSyncService {
private const CALENDAR_URI = 'pfadfinder-geburtstage';
private const CALENDAR_NAME = 'Pfadfinder Geburtstage';
private MemberMapper $memberMapper;
private IDBConnection $db;
private PermissionService $permissionService;
private IUserSession $userSession;
private LoggerInterface $logger;
public function __construct(
MemberMapper $memberMapper,
IDBConnection $db,
PermissionService $permissionService,
IUserSession $userSession,
LoggerInterface $logger
) {
$this->memberMapper = $memberMapper;
$this->db = $db;
$this->permissionService = $permissionService;
$this->userSession = $userSession;
$this->logger = $logger;
}
/**
* Assert that the current user has write-level access.
*
* Sync operations modify data (calendar events linked to members),
* so at minimum write permission is required.
*
* In background job context (no user session), this check is skipped
* as background jobs are system-level operations.
*
* @throws \RuntimeException if a user is logged in but lacks permission
*/
private function assertWritePermission(): void {
$user = $this->userSession->getUser();
if ($user === null) {
// Background job context — no user session available.
// Background jobs are trusted system-level operations.
$this->logger->debug('Calendar sync running in background context (no user session)', [
'app' => 'mitgliederverwaltung',
]);
return;
}
$userId = $user->getUID();
if (!$this->permissionService->canWrite($userId)) {
$this->logger->warning('Calendar sync denied: insufficient permissions', [
'userId' => $userId,
'app' => 'mitgliederverwaltung',
]);
throw new \RuntimeException('Calendar sync requires write permission');
}
}
/**
* Sync a single member's birthday event.
*
* Creates, updates, or deletes the calendar event based on member state:
* - Active member with role != Erziehungsberechtigter => create/update event
* - Inactive or Erziehungsberechtigter => delete event
*
* @param Member $member The member to sync
*/
public function syncMember(Member $member): void {
$this->assertWritePermission();
$shouldHaveEvent = $this->shouldHaveEvent($member);
$hasEvent = $member->getCalendarEventUri() !== null;
if ($shouldHaveEvent && !$hasEvent) {
$this->createEvent($member);
} elseif ($shouldHaveEvent && $hasEvent) {
$this->updateEvent($member);
} elseif (!$shouldHaveEvent && $hasEvent) {
$this->deleteEvent($member);
}
}
/**
* Full reconciliation sync: compare all members with calendar events.
*
* Catches drift from missed queue events.
*
* @return array{created: int, updated: int, deleted: int}
*/
public function fullSync(): array {
$this->assertWritePermission();
$stats = ['created' => 0, 'updated' => 0, 'deleted' => 0];
try {
$members = $this->memberMapper->findAll();
foreach ($members as $member) {
$shouldHaveEvent = $this->shouldHaveEvent($member);
$hasEvent = $member->getCalendarEventUri() !== null;
if ($shouldHaveEvent && !$hasEvent) {
$this->createEvent($member);
$stats['created']++;
} elseif ($shouldHaveEvent && $hasEvent) {
$this->updateEvent($member);
$stats['updated']++;
} elseif (!$shouldHaveEvent && $hasEvent) {
$this->deleteEvent($member);
$stats['deleted']++;
}
}
$this->logger->info('Calendar full sync completed', [
'created' => $stats['created'],
'updated' => $stats['updated'],
'deleted' => $stats['deleted'],
'app' => 'mitgliederverwaltung',
]);
} catch (\Exception $e) {
$this->logger->error('Calendar full sync failed', [
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
}
return $stats;
}
/**
* Enqueue a member for calendar sync.
* Processed by the SyncQueueJob background task.
*/
public function enqueueSync(int $memberId): void {
$qb = $this->db->getQueryBuilder();
$qb->insert('mv_sync_queue')
->values([
'entity_type' => $qb->createNamedParameter('calendar'),
'entity_id' => $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT),
'created_at' => $qb->createNamedParameter(
(new \DateTime())->format('Y-m-d H:i:s')
),
]);
try {
$qb->executeStatement();
} catch (\Exception $e) {
$this->logger->warning('Failed to enqueue calendar sync', [
'memberId' => $memberId,
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
}
}
/**
* Process pending sync queue entries.
*
* @return int Number of entries processed
*/
public function processQueue(): int {
$qb = $this->db->getQueryBuilder();
$qb->select('*')
->from('mv_sync_queue')
->where($qb->expr()->eq('entity_type', $qb->createNamedParameter('calendar')))
->orderBy('created_at', 'ASC')
->setMaxResults(100);
$result = $qb->executeQuery();
$processed = 0;
while ($row = $result->fetch()) {
try {
$member = $this->memberMapper->findById((int)$row['entity_id']);
$this->syncMember($member);
$processed++;
} catch (\Exception $e) {
$this->logger->warning('Failed to sync member from queue', [
'entityId' => $row['entity_id'],
'exception' => $e,
'app' => 'mitgliederverwaltung',
]);
}
// Delete processed entry
$deleteQb = $this->db->getQueryBuilder();
$deleteQb->delete('mv_sync_queue')
->where($deleteQb->expr()->eq('id', $deleteQb->createNamedParameter(
(int)$row['id'],
IQueryBuilder::PARAM_INT
)));
$deleteQb->executeStatement();
}
$result->closeCursor();
return $processed;
}
/**
* Create a birthday calendar event for a member.
*/
private function createEvent(Member $member): void {
$eventUri = $this->generateEventUri($member);
$vcalendar = $this->buildVEvent($member);
// Store the event URI on the member for efficient updates
$this->updateMemberCalendarUri($member->getId(), $eventUri);
// Store event data for CalDAV sync
$this->storeCalendarEvent($eventUri, $vcalendar);
$this->logger->debug('Created calendar event', [
'memberId' => $member->getId(),
'eventUri' => $eventUri,
'app' => 'mitgliederverwaltung',
]);
}
/**
* Update an existing birthday calendar event.
*/
private function updateEvent(Member $member): void {
$eventUri = $member->getCalendarEventUri();
$vcalendar = $this->buildVEvent($member);
$this->storeCalendarEvent($eventUri, $vcalendar);
$this->logger->debug('Updated calendar event', [
'memberId' => $member->getId(),
'eventUri' => $eventUri,
'app' => 'mitgliederverwaltung',
]);
}
/**
* Delete a birthday calendar event for a member.
*/
public function deleteEvent(Member $member): void {
$this->assertWritePermission();
$eventUri = $member->getCalendarEventUri();
if ($eventUri !== null) {
$this->removeCalendarEvent($eventUri);
$this->updateMemberCalendarUri($member->getId(), null);
$this->logger->debug('Deleted calendar event', [
'memberId' => $member->getId(),
'eventUri' => $eventUri,
'app' => 'mitgliederverwaltung',
]);
}
}
// ── Helpers ──────────────────────────────────────────────────────
/**
* Determine if a member should have a birthday calendar event.
*/
private function shouldHaveEvent(Member $member): bool {
// Only active members who are not Erziehungsberechtigte and have a birthday
return $member->getStatus() === 'aktiv'
&& strtolower($member->getRolle()) !== 'erziehungsberechtigter'
&& $member->getDeletedAt() === null
&& $member->getGeburtsdatum() !== null;
}
/**
* Build a VCalendar event for a member's birthday.
*/
private function buildVEvent(Member $member): string {
$birthYear = substr($member->getGeburtsdatum(), 0, 4);
$birthMonth = substr($member->getGeburtsdatum(), 5, 2);
$birthDay = substr($member->getGeburtsdatum(), 8, 2);
$summary = sprintf(
'Geburtstag: %s %s (*%s)',
$member->getVorname(),
$member->getNachname(),
$birthYear
);
$dtstart = $birthMonth . $birthDay;
$vcal = new VCalendar();
$vevent = $vcal->add('VEVENT', [
'SUMMARY' => $summary,
'DTSTART' => new \DateTime($member->getGeburtsdatum()),
'RRULE' => 'FREQ=YEARLY',
'TRANSP' => 'TRANSPARENT',
]);
// All-day event
$vevent->DTSTART['VALUE'] = 'DATE';
return $vcal->serialize();
}
/**
* Generate a unique event URI for a member.
*/
private function generateEventUri(Member $member): string {
return 'mv-birthday-' . $member->getId() . '.ics';
}
/**
* Store or update a calendar event in the sync table.
*/
private function storeCalendarEvent(string $eventUri, string $vcalendarData): void {
// Check if exists
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('mv_calendar_events')
->where($qb->expr()->eq('event_uri', $qb->createNamedParameter($eventUri)));
$result = $qb->executeQuery();
$existing = $result->fetch();
$result->closeCursor();
if ($existing) {
$updateQb = $this->db->getQueryBuilder();
$updateQb->update('mv_calendar_events')
->set('vcalendar_data', $updateQb->createNamedParameter($vcalendarData))
->set('updated_at', $updateQb->createNamedParameter(
(new \DateTime())->format('Y-m-d H:i:s')
))
->where($updateQb->expr()->eq('event_uri', $updateQb->createNamedParameter($eventUri)));
$updateQb->executeStatement();
} else {
$insertQb = $this->db->getQueryBuilder();
$insertQb->insert('mv_calendar_events')
->values([
'event_uri' => $insertQb->createNamedParameter($eventUri),
'vcalendar_data' => $insertQb->createNamedParameter($vcalendarData),
'calendar_uri' => $insertQb->createNamedParameter(self::CALENDAR_URI),
'created_at' => $insertQb->createNamedParameter(
(new \DateTime())->format('Y-m-d H:i:s')
),
'updated_at' => $insertQb->createNamedParameter(
(new \DateTime())->format('Y-m-d H:i:s')
),
]);
$insertQb->executeStatement();
}
}
/**
* Remove a calendar event from the sync table.
*/
private function removeCalendarEvent(string $eventUri): void {
$qb = $this->db->getQueryBuilder();
$qb->delete('mv_calendar_events')
->where($qb->expr()->eq('event_uri', $qb->createNamedParameter($eventUri)));
$qb->executeStatement();
}
/**
* Update a member's calendar_event_uri field.
*/
private function updateMemberCalendarUri(int $memberId, ?string $uri): void {
$qb = $this->db->getQueryBuilder();
$qb->update('mv_members')
->set('calendar_event_uri', $qb->createNamedParameter($uri))
->where($qb->expr()->eq('id', $qb->createNamedParameter($memberId, IQueryBuilder::PARAM_INT)));
$qb->executeStatement();
}
// ── Juleica reminder sync (Issue #160) ──────────────────────────
/**
* Sync a single member's Juleica expiry reminder.
*
* Creates a one-time reminder 2 months before juleica_ablaufdatum.
* Removes the reminder if the date is cleared.
* Updates the reminder if the date changes.
*
* @param Member $member The member to sync
*/
public function syncJuleicaReminder(Member $member): void {
$this->assertWritePermission();
$juleicaUri = $this->generateJuleicaEventUri($member);
$hasExpiry = $member->getJuleicaAblaufdatum() !== null
&& $member->getJuleicaAblaufdatum() !== '';
$hasEvent = $this->juleicaEventExists($juleicaUri);
if ($hasExpiry && $member->getDeletedAt() === null) {
$vcalendar = $this->buildJuleicaVEvent($member);
$this->storeCalendarEvent($juleicaUri, $vcalendar);
$this->logger->debug('Synced Juleica reminder', [
'memberId' => $member->getId(),
'eventUri' => $juleicaUri,
'app' => 'mitgliederverwaltung',
]);
} elseif ($hasEvent) {
$this->removeCalendarEvent($juleicaUri);
$this->logger->debug('Removed Juleica reminder', [
'memberId' => $member->getId(),
'eventUri' => $juleicaUri,
'app' => 'mitgliederverwaltung',
]);
}
}
/**
* Build a VCalendar event for a member's Juleica expiry reminder.
* The event is placed 2 months before the Ablaufdatum.
*/
private function buildJuleicaVEvent(Member $member): string {
$expiry = new \DateTime($member->getJuleicaAblaufdatum());
$reminderDate = (clone $expiry)->modify('-2 months');
$summary = sprintf(
'Juleica-Erneuerung: %s %s',
$member->getVorname(),
$member->getNachname()
);
$description = sprintf(
'Die Juleica von %s %s laeuft am %s ab. Bitte rechtzeitig die Erneuerung einleiten.',
$member->getVorname(),
$member->getNachname(),
$expiry->format('d.m.Y')
);
if ($member->getJuleicaNummer()) {
$description .= "\nJuleica-Nummer: " . $member->getJuleicaNummer();
}
$vcal = new VCalendar();
$vevent = $vcal->add('VEVENT', [
'SUMMARY' => $summary,
'DESCRIPTION' => $description,
'DTSTART' => $reminderDate,
'TRANSP' => 'TRANSPARENT',
]);
// All-day event (non-recurring)
$vevent->DTSTART['VALUE'] = 'DATE';
return $vcal->serialize();
}
/**
* Generate a unique event URI for a member's Juleica reminder.
*/
private function generateJuleicaEventUri(Member $member): string {
return 'mv-juleica-' . $member->getId() . '.ics';
}
/**
* Check if a Juleica reminder event exists in the calendar staging table.
*/
private function juleicaEventExists(string $eventUri): bool {
$qb = $this->db->getQueryBuilder();
$qb->select('id')
->from('mv_calendar_events')
->where($qb->expr()->eq('event_uri', $qb->createNamedParameter($eventUri)));
$result = $qb->executeQuery();
$exists = $result->fetch() !== false;
$result->closeCursor();
return $exists;
}
}