495 lines
17 KiB
PHP
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;
|
|
}
|
|
}
|