Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,21 @@ jobs:
uses: actions/checkout@v7
with:
path: app
- name: LocalBase-Referenz bestimmen
id: localbase-ref
env:
CANDIDATE_REF: ${{ github.head_ref || github.ref_name }}
run: |
if git ls-remote --exit-code --heads https://github.com/Filzmann/nextcloud-localbase.git "refs/heads/$CANDIDATE_REF" >/dev/null 2>&1; then
echo "ref=$CANDIDATE_REF" >> "$GITHUB_OUTPUT"
else
echo "ref=main" >> "$GITHUB_OUTPUT"
fi
- name: LocalBase auschecken
uses: actions/checkout@v7
with:
repository: Filzmann/nextcloud-localbase
ref: ${{ steps.localbase-ref.outputs.ref }}
path: localbase
- name: PHP einrichten
uses: shivammathur/setup-php@v2
Expand All @@ -50,10 +61,21 @@ jobs:
uses: actions/checkout@v7
with:
path: app
- name: LocalBase-Referenz bestimmen
id: localbase-ref
env:
CANDIDATE_REF: ${{ github.head_ref || github.ref_name }}
run: |
if git ls-remote --exit-code --heads https://github.com/Filzmann/nextcloud-localbase.git "refs/heads/$CANDIDATE_REF" >/dev/null 2>&1; then
echo "ref=$CANDIDATE_REF" >> "$GITHUB_OUTPUT"
else
echo "ref=main" >> "$GITHUB_OUTPUT"
fi
- name: LocalBase auschecken
uses: actions/checkout@v7
with:
repository: Filzmann/nextcloud-localbase
ref: ${{ steps.localbase-ref.outputs.ref }}
path: localbase
- name: Node.js einrichten
uses: actions/setup-node@v6
Expand Down
8 changes: 5 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ Nextcloud-App-ID:
- Raumloeschungen loeschen die zugehoerigen Buchungen. Die UI muss diese Auswirkung vor der Aktion deutlich bestaetigen.
- Samstage, Sonntage und gesetzliche Feiertage in Berlin werden in der Monatsansicht textlich und optisch gekennzeichnet.
- Der WordPress-Raumplaner ist nur fachliche Referenz. WordPress-IDs, Capabilities, Nonces, Shortcodes und Tabellen werden nicht uebernommen.
- WordPress-Bestandsdaten werden nicht importiert. Der app-eigene Adminabschnitt installiert neutrale Räume und Buchungen ausschließlich als manuell bestätigten synthetischen Demo-Pack.
- Beispielbuchungen gehören einem explizit registrierten lokalen Demokonto; ein vorhandenes fremdes oder LDAP-verwaltetes Konto wird niemals dafür wiederverwendet.

## Architektur und Sicherheit

Expand All @@ -38,9 +40,9 @@ Nextcloud-App-ID:

## Gemeinsame Suite-Navigation

- AD Raumplaner besitzt keinen eigenen Nextcloud-Hauptnavigationseintrag.
- `orgsuite` stellt den Einstieg `AD` und das zentrale Menue bereit.
- Das Template bindet `data-suite="ad"` und `data-current-app="adroom"` ein.
- Ohne aktive OrgSuite registriert AD Raumplaner einen eigenen Nextcloud-Hauptnavigationseintrag. Ab zwei AD-Produkten ersetzt `orgsuite` diesen durch den gemeinsamen Einstieg `AD`.
- Das Template stellt den optionalen Menühost mit `data-suite="ad"` und `data-current-app="adroom"` bereit, lädt aber keine OrgSuite-Assets direkt.
- Ohne Kalender oder Assistenzplanung bleiben Raumbuchungen vollständig manuell nutzbar; optionale Direktbuchungen dürfen nicht als harte Abhängigkeit modelliert werden.
- Menuesichtbarkeit ist keine Berechtigung.

## Git, DDEV und Tests
Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Changelog

## 0.9.0-rc.1

- Eigenständige Navigation ohne OrgSuite ergänzt.
- Raumverfügbarkeits- und Buchungsfähigkeiten über optionale LocalBase-Verträge veröffentlicht.
- Ungültige harte App-Abhängigkeiten aus den Nextcloud-Metadaten entfernt.

## 0.8.8-rc.1

- Eigener Nextcloud-Adminabschnitt für die ausschließlich appbezogene Raumverwaltung.
Expand Down
10 changes: 4 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,16 +6,14 @@ Monatliche, zeitlich ausgerichtete Raumbelegung mit kollisionsfreien Buchungen,

- Nextcloud 34
- PHP 8.3 oder neuer innerhalb des von Nextcloud 34 unterstützten Bereichs
- Abhängigkeiten: `localbase`, `orgsuite`
- Laufzeitbasis: `localbase`; `orgsuite` ist ab zwei AD-Fachprodukten optional aktiv
- App-ID und Installationsordner: `adroom`

## Installation

```bash
sudo -u www-data php occ app:enable localbase
sudo -u www-data php occ app:enable orgsuite
sudo -u www-data php occ app:enable adroom
```
Für Staging und Auslieferung das Produktbundle `ad-product-adroom-<release>.tar.gz` und dessen enthaltenes `install.sh` verwenden. Es prüft und installiert LocalBase automatisch; ab dem zweiten AD-Fachprodukt aktiviert es OrgSuite.

AD Raumplaner funktioniert einzeln; Buchungen bleiben ohne Kalender oder Assistenzplanung manuell nutzbar.

Räume werden nach der Aktivierung im eigenen Nextcloud-Adminabschnitt `AD Raumplaner` eingerichtet. `adroom:demo:seed` ist ausschließlich für synthetische Testdaten bestimmt.

Expand Down
4 changes: 1 addition & 3 deletions appinfo/info.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<name>AD Raumplaner</name>
<summary>Monatliche Raumbelegung und kollisionsfreie Raumbuchungen.</summary>
<description>Verwaltet Räume und eigene beziehungsweise administrative Buchungen in einer kompakten Monatsmatrix.</description>
<version>0.8.8-rc.1</version>
<version>0.9.0-rc.2</version>
<licence>agpl</licence>
<author>Simon</author>
<website>https://github.com/Filzmann/ad-suite</website>
Expand All @@ -16,8 +16,6 @@
<dependencies>
<php min-version="8.3"/>
<nextcloud min-version="34" max-version="34"/>
<app>localbase</app>
<app>orgsuite</app>
</dependencies>
<settings>
<admin>OCA\AdRoom\Settings\Admin</admin>
Expand Down
2 changes: 1 addition & 1 deletion appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,5 @@
['name' => 'api#createRoom', 'url' => '/api/rooms', 'verb' => 'POST'],
['name' => 'api#updateRoom', 'url' => '/api/rooms/{id}', 'verb' => 'PUT'],
['name' => 'api#deleteRoom', 'url' => '/api/rooms/{id}', 'verb' => 'DELETE'],
['name' => 'demo_admin#install', 'url' => '/api/admin/demo-pack/install', 'verb' => 'POST'],
]];

4 changes: 4 additions & 0 deletions css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@
.adr-icon-button:focus-visible { outline:2px solid var(--color-primary-element); outline-offset:2px; }
.adr-add { flex:0 0 auto; }
.adr-room-form { margin-top:16px; padding:12px; border:1px solid var(--color-border); border-radius:var(--border-radius-large); }
.adr-admin-demo { margin-top:24px; padding-top:18px; border-top:1px solid var(--color-border); }
.adr-demo-confirm { display:block; margin:16px 0 10px; }
.adr-notice.is-success { border:2px solid var(--color-success); color:var(--color-main-text); }
.adr-notice.is-error { border:2px solid var(--color-error); background:var(--color-error-hover); color:var(--color-main-text); }
.adr-room-table input { min-width:110px; width:100%; }
.adr-dialog { width:min(520px,calc(100vw - 32px)); border:1px solid var(--color-border); border-radius:var(--border-radius-large); padding:16px; background:var(--color-main-background); color:var(--color-main-text); }
.adr-dialog:not([open]) { display:none; }
Expand Down
23 changes: 23 additions & 0 deletions js/admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,29 @@
onRemove: room => workflow.remove(room),
});

const demoConfirmation = byId('adr-demo-confirm');
const demoButton = byId('adr-demo-install');
const demoNotice = byId('adr-demo-notice');
demoConfirmation.addEventListener('change', () => { demoButton.disabled = !demoConfirmation.checked; });
demoButton.addEventListener('click', async () => {
if (!demoConfirmation.checked) return;
demoButton.disabled = true;
demoNotice.hidden = false;
demoNotice.className = 'adr-notice';
demoNotice.textContent = 'Demo-Pack wird geprüft und installiert …';
try {
const response = await client.request('/api/admin/demo-pack/install', { method: 'POST', body: '{}' });
demoNotice.classList.add('is-success');
demoNotice.textContent = `${response.result.rooms} Räume synchronisiert; ${response.result.createdBookings} Buchungen angelegt.`;
demoConfirmation.checked = false;
await load();
} catch (error) {
demoNotice.classList.add('is-error');
demoNotice.textContent = error.message || 'Das Demo-Pack konnte nicht installiert werden.';
demoButton.disabled = false;
}
});

async function load() {
try {
const now = new Date();
Expand Down
19 changes: 17 additions & 2 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,28 @@

namespace OCA\AdRoom\AppInfo;

use OCA\AdRoom\Listener\IntegrationCapabilityQueryListener;
use OCA\AdRoom\Listener\StandaloneNavigationListener;
use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent;
use OCP\AppFramework\App;
use OCP\AppFramework\Bootstrap\IBootContext;
use OCP\AppFramework\Bootstrap\IBootstrap;
use OCP\AppFramework\Bootstrap\IRegistrationContext;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;

final class Application extends App {
/** Zweck: Registriert Raumfähigkeiten und Standalone-Navigation im Nextcloud-Bootstrap. */
final class Application extends App implements IBootstrap {
public const APP_ID = 'adroom';

public function __construct(array $urlParams = []) {
parent::__construct(self::APP_ID, $urlParams);
}
}

public function register(IRegistrationContext $context): void {
$context->registerEventListener(IntegrationCapabilityQueryEvent::class, IntegrationCapabilityQueryListener::class);
$context->registerEventListener(LoadAdditionalEntriesEvent::class, StandaloneNavigationListener::class);
}

public function boot(IBootContext $context): void {
}
}
35 changes: 7 additions & 28 deletions lib/Command/SeedDemoCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,39 +4,18 @@

namespace OCA\AdRoom\Command;

use DateTimeImmutable;
use DateTimeZone;
use OCA\AdRoom\Exception\BookingConflictException;
use OCA\AdRoom\Service\BookingService;
use OCA\AdRoom\Service\RoomService;
use OCP\IUserManager;
use OCA\AdRoom\Service\RoomDemoPackService;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

/** Zweck: Erzeugt idempotent benannte neutrale Räume und Beispielbuchungen. */
/** Zweck: Stellt den sicheren Raum-Demo-Pack für automatisierte Staging-Setups bereit. */
final class SeedDemoCommand extends Command {
public function __construct(private RoomService $rooms,private BookingService $bookings,private IUserManager $users) { parent::__construct(); }
protected function configure(): void { $this->setName('adroom:demo:seed')->setDescription('Erzeugt neutrale Raumplaner-Demodaten.')->addOption('user',null,InputOption::VALUE_REQUIRED,'UID fuer Beispielbuchungen','admin'); }
protected function execute(InputInterface $input,OutputInterface $output): int {
$uid=(string)$input->getOption('user');
if ($this->users->get($uid)===null) { $output->writeln("<error>Benutzer {$uid} existiert nicht.</error>"); return self::FAILURE; }
$definitions=[
['name'=>'Besprechungsraum Nord','description'=>'Kleiner Besprechungsraum','sortOrder'=>10],
['name'=>'Besprechungsraum Süd','description'=>'Besprechungsraum für Teams','sortOrder'=>20],
['name'=>'Konferenzraum','description'=>'Großer Raum für Sitzungen und Fortbildungen','sortOrder'=>30],
];
$existing=[]; foreach ($this->rooms->all() as $room) $existing[$room->name()]=$room->id();
foreach ($definitions as $definition) if (!isset($existing[$definition['name']])) $existing[$definition['name']]=$this->rooms->save(null,$definition['name'],$definition['description'],$definition['sortOrder']);
$day=new DateTimeImmutable('next monday',new DateTimeZone('Europe/Berlin'));
$samples=[['Besprechungsraum Nord','10:00','11:00','AT','ASN Nordost'],['Besprechungsraum Süd','12:00','13:30','Sitzung','Büroteam Süd'],['Konferenzraum','14:00','16:00','Fortbildung','Arbeitsschutz']];
$created=0;
foreach ($samples as [$name,$start,$end,$purpose,$title]) {
try { $this->bookings->create((int)$existing[$name],$day->format('Y-m-d').'T'.$start,$day->format('Y-m-d').'T'.$end,$purpose,$title,$uid); $created++; }
catch (BookingConflictException) {}
}
$output->writeln('<info>Drei benannte Räume synchronisiert; '.$created.' Beispielbuchungen erzeugt.</info>');
public function __construct(private RoomDemoPackService $demoPack) { parent::__construct(); }
protected function configure(): void { $this->setName('adroom:demo:seed')->setDescription('Erzeugt synthetische Raumplaner-Demodaten.'); }
protected function execute(InputInterface $input, OutputInterface $output): int {
$result = $this->demoPack->install();
$output->writeln("<info>{$result['rooms']} Räume synchronisiert; {$result['createdBookings']} Beispielbuchungen erzeugt.</info>");
return self::SUCCESS;
}
}
33 changes: 33 additions & 0 deletions lib/Controller/DemoAdminController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
<?php

declare(strict_types=1);

namespace OCA\AdRoom\Controller;

use OCA\AdRoom\AppInfo\Application;
use OCA\AdRoom\Service\RoomDemoPackService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\JSONResponse;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserSession;
use Psr\Log\LoggerInterface;

/** Zweck: Startet den Raum-Demo-Pack ausschließlich mit Admin- und CSRF-Schutz. */
final class DemoAdminController extends Controller {
public function __construct(IRequest $request, private IUserSession $session, private IGroupManager $groups, private RoomDemoPackService $demoPack, private LoggerInterface $logger) { parent::__construct(Application::APP_ID, $request); }
public function install(): JSONResponse {
if (!$this->isAdmin()) return new JSONResponse(['error' => 'Keine Berechtigung.'], Http::STATUS_FORBIDDEN);
try {
return new JSONResponse(['result' => $this->demoPack->install()]);
} catch (\Throwable $error) {
$this->logger->error('Raum-Demo-Pack konnte nicht installiert werden.', ['exception' => $error]);
return new JSONResponse(['error' => $error->getMessage()], Http::STATUS_BAD_REQUEST);
}
}
private function isAdmin(): bool {
$user = $this->session->getUser();
return $user !== null && $this->groups->isAdmin($user->getUID());
}
}
22 changes: 22 additions & 0 deletions lib/Listener/IntegrationCapabilityQueryListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
<?php

declare(strict_types=1);

namespace OCA\AdRoom\Listener;

use OCA\AdRoom\AppInfo\Application;
use OCA\LocalBase\Integration\AdIntegrationCapabilities;
use OCA\LocalBase\Integration\IntegrationCapabilityQueryEvent;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;

/** @template-implements IEventListener<IntegrationCapabilityQueryEvent> */
final class IntegrationCapabilityQueryListener implements IEventListener {
public function handle(Event $event): void {
if (!$event instanceof IntegrationCapabilityQueryEvent) return;
$event->provide(Application::APP_ID, [
AdIntegrationCapabilities::ROOM_AVAILABILITY_READ,
AdIntegrationCapabilities::ROOM_BOOKING_WRITE,
]);
}
}
21 changes: 21 additions & 0 deletions lib/Listener/StandaloneNavigationListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

declare(strict_types=1);

namespace OCA\AdRoom\Listener;

use OCA\LocalBase\Service\StandaloneAppNavigationService;
use OCP\EventDispatcher\Event;
use OCP\EventDispatcher\IEventListener;
use OCP\Navigation\Events\LoadAdditionalEntriesEvent;

/** @template-implements IEventListener<LoadAdditionalEntriesEvent> */
final class StandaloneNavigationListener implements IEventListener {
public function __construct(private StandaloneAppNavigationService $navigation) {
}

public function handle(Event $event): void {
if (!$event instanceof LoadAdditionalEntriesEvent) return;
$this->navigation->addWhenStandalone('adroom', 'Räume', 'adroom.page.index', 'app.svg', 83);
}
}
50 changes: 50 additions & 0 deletions lib/Service/RoomDemoPackService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace OCA\AdRoom\Service;

use DateTimeImmutable;
use DateTimeZone;
use OCA\AdRoom\Exception\BookingConflictException;
use OCA\LocalBase\Service\DemoAccountProvisioningService;

/** Zweck: Installiert neutrale Räume und Beispielbuchungen unter einem registrierten lokalen Demokonto. */
final class RoomDemoPackService {
public function __construct(private DemoAccountProvisioningService $accounts, private RoomService $rooms, private BookingService $bookings) {}

/** @return array{accounts:array,rooms:int,createdBookings:int,skippedBookings:int} */
public function install(): array {
$accounts = $this->accounts->provision('ad-suite-demo', [[
'uid' => 'ad-demo-room', 'displayName' => 'Romy Baum (Raumplaner-Demo)', 'groups' => [],
]]);
$definitions = [
['name' => 'Besprechungsraum Nord', 'description' => 'Kleiner Besprechungsraum', 'sortOrder' => 10],
['name' => 'Besprechungsraum Süd', 'description' => 'Besprechungsraum für Teams', 'sortOrder' => 20],
['name' => 'Konferenzraum', 'description' => 'Großer Raum für Sitzungen und Fortbildungen', 'sortOrder' => 30],
];
$existing = [];
foreach ($this->rooms->all() as $room) $existing[$room->name()] = $room->id();
foreach ($definitions as $definition) {
if (!isset($existing[$definition['name']])) $existing[$definition['name']] = $this->rooms->save(null, $definition['name'], $definition['description'], $definition['sortOrder']);
}

$day = new DateTimeImmutable('next monday', new DateTimeZone('Europe/Berlin'));
$samples = [
['Besprechungsraum Nord', '10:00', '11:00', 'AT', 'ASN Team A'],
['Besprechungsraum Süd', '12:00', '13:30', 'Sitzung', 'Büroteam Süd'],
['Konferenzraum', '14:00', '16:00', 'Fortbildung', 'Arbeitsschutz'],
];
$createdBookings = 0;
$skippedBookings = 0;
foreach ($samples as [$name, $start, $end, $purpose, $title]) {
try {
$this->bookings->create((int)$existing[$name], $day->format('Y-m-d') . 'T' . $start, $day->format('Y-m-d') . 'T' . $end, $purpose, $title, 'ad-demo-room');
$createdBookings++;
} catch (BookingConflictException) {
$skippedBookings++;
}
}
return ['accounts' => $accounts, 'rooms' => count($definitions), 'createdBookings' => $createdBookings, 'skippedBookings' => $skippedBookings];
}
}
7 changes: 7 additions & 0 deletions templates/admin.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,11 @@
<label>Reihenfolge <input name="sortOrder" type="number" min="0" value="0"></label>
<button type="submit" class="primary">Raum anlegen</button>
</form>
<section class="adr-admin-demo" aria-labelledby="adr-demo-heading">
<h3 id="adr-demo-heading">Demo-Pack</h3>
<p>Das Pack legt drei neutrale Räume und Beispielbuchungen unter einem synthetischen lokalen Demokonto an. Es wird nicht automatisch installiert und importiert keine Bestandsdaten.</p>
<p id="adr-demo-notice" class="adr-notice" role="status" aria-live="polite" hidden></p>
<label class="adr-demo-confirm"><input id="adr-demo-confirm" type="checkbox"> Ich bestätige die Installation synthetischer Demodaten.</label>
<button id="adr-demo-install" type="button" class="primary" disabled>Raum-Demo-Pack installieren</button>
</section>
</section>
Loading