Compare commits

...

33 Commits

Author SHA1 Message Date
philipp lang d38989f302 Lint
continuous-integration/drone/tag Build is passing Details
continuous-integration/drone/push Build is passing Details
2023-06-15 00:33:13 +02:00
philipp lang a434388bcb Fixed tests 2023-06-15 00:12:20 +02:00
philipp lang 48d9d9cc92 Fix: Delete local addresses when deleting maildispatcher 2023-06-15 00:08:01 +02:00
philipp lang c2016af587 Store maildispatcher 2023-06-14 23:20:37 +02:00
philipp lang 4057f9fc8d Fix Uuids for select 2023-06-14 23:20:13 +02:00
philipp lang 89f5489e86 Fix height of MultipleSelect 2023-06-14 23:19:46 +02:00
philipp lang 7497593bb2 Add ValidationAttributes for Maildispatcher 2023-06-14 23:19:19 +02:00
Philipp Lang 0080a54aaf Add mailgateway form
continuous-integration/drone/push Build is failing Details
2023-06-14 17:29:22 +02:00
Philipp Lang d41aa466b1 Add member filter 2023-06-14 17:12:03 +02:00
Philipp Lang 188429af55 Add Search action for contribution search 2023-06-14 17:12:03 +02:00
Philipp Lang 2cb04ee142 Add max height for multiple select window 2023-06-14 17:12:03 +02:00
philipp lang b87b37a673 Add create action for maildispatcher 2023-06-14 17:12:03 +02:00
philipp lang 8f3cc95300 Fixed mailman test 2023-06-14 17:12:03 +02:00
Philipp Lang 904ec829c1 Add member management for mailman 2023-06-14 17:12:03 +02:00
Philipp Lang a74d0936a2 --wip-- [skip ci] 2023-06-14 17:12:03 +02:00
philipp lang da3197395f Fixed: Match setting path when URL contains query string 2023-06-14 17:12:03 +02:00
philipp lang 20973b3664 Fixed: Add listeners for button 2023-06-14 17:12:03 +02:00
philipp lang b54472c14e Update mailgateway 2023-06-14 17:12:03 +02:00
philipp lang 8b2bdae5d6 Add update for mailgateway 2023-06-14 17:12:03 +02:00
philipp lang ab43d42869 Fixed tests 2023-06-14 17:12:03 +02:00
philipp lang 79f818c0f9 Fix index 2023-06-14 17:12:03 +02:00
philipp lang 4a452ef1fe Add type validation 2023-06-14 17:12:03 +02:00
philipp lang 36dcff9738 Simplify tests 2023-06-14 17:12:03 +02:00
philipp lang a880333f35 Add store button 2023-06-14 17:12:03 +02:00
philipp lang 68ecce179e Add params as type 2023-06-14 17:12:03 +02:00
philipp lang 06d512e8ae rename route 2023-06-14 17:12:03 +02:00
philipp lang 9442714086 Add indexTest for mailgateway 2023-06-14 17:12:03 +02:00
philipp lang c653a4b0c1 Add test for mailman type 2023-06-14 17:12:02 +02:00
Philipp Lang 0dcb3b7d5b Add mailman check 2023-06-14 17:12:02 +02:00
Philipp Lang a15b54f98d Add mailman type with params 2023-06-14 17:12:02 +02:00
Philipp Lang f75201dfaa Add: Store new mailgateway 2023-06-14 17:12:02 +02:00
Philipp Lang 27c61ff8af Add index for mail gateways 2023-06-14 17:12:02 +02:00
Philipp Lang d2a000cb31 Ad Contract for settings 2023-06-14 17:12:02 +02:00
73 changed files with 2224 additions and 191 deletions

View File

@ -2,9 +2,11 @@
namespace App\Invoice;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use App\Setting\LocalSettings;
class InvoiceSettings extends LocalSettings
class InvoiceSettings extends LocalSettings implements Indexable, Storeable
{
public string $from_long;
@ -41,7 +43,7 @@ class InvoiceSettings extends LocalSettings
return SettingIndexAction::class;
}
public static function saveAction(): string
public static function storeAction(): string
{
return SettingSaveAction::class;
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class CreateAction
{
use AsAction;
public function asController(): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler erstellen');
return Inertia::render('maildispatcher/MaildispatcherForm', [
'meta' => MaildispatcherResource::meta(),
]);
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\RedirectResponse;
use Lorisleiva\Actions\Concerns\AsAction;
class DestroyAction
{
use AsAction;
public function handle(Maildispatcher $maildispatcher): void
{
$maildispatcher->delete();
}
public function asController(Maildispatcher $maildispatcher): RedirectResponse
{
$this->handle($maildispatcher);
return redirect()->back();
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class EditAction
{
use AsAction;
public function asController(Maildispatcher $maildispatcher): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler bearbeiten');
return Inertia::render('maildispatcher/MaildispatcherForm', [
'data' => new MaildispatcherResource($maildispatcher),
'meta' => MaildispatcherResource::meta(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use App\Maildispatcher\Resources\MaildispatcherResource;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
public function asController(ActionRequest $request): Response
{
session()->put('menu', 'maildispatcher');
session()->put('title', 'Mail-Verteiler');
return Inertia::render('maildispatcher/MaildispatcherIndex', [
'data' => MaildispatcherResource::collection(Maildispatcher::with('gateway')->paginate(10)),
]);
}
}

View File

@ -0,0 +1,32 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Data\MailEntry;
use App\Maildispatcher\Models\Maildispatcher;
use App\Member\FilterScope;
use App\Member\Member;
use Illuminate\Support\Collection;
use Lorisleiva\Actions\Concerns\AsAction;
class ResyncAction
{
use AsAction;
public function handle(): void
{
foreach (Maildispatcher::get() as $dispatcher) {
$dispatcher->gateway->type->sync($dispatcher->name, $dispatcher->gateway->domain, $this->getResults($dispatcher));
}
}
/**
* @return Collection<int, MailEntry>
*/
public function getResults(Maildispatcher $dispatcher): Collection
{
return Member::search(data_get($dispatcher->filter, 'search', ''))->query(
fn ($q) => $q->select('*')->withFilter(FilterScope::fromPost($dispatcher->filter))
)->get()->filter(fn ($member) => $member->email || $member->email_parents)->map(fn ($member) => MailEntry::from(['email' => $member->email ?: $member->email_parents]));
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class StoreAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*/
public function handle(array $input): void
{
Maildispatcher::create([
...$input,
'filter' => (object) $input['filter'],
]);
ResyncAction::dispatch();
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'gateway_id' => 'Verbindung',
];
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'gateway_id' => 'required|exists:mailgateways,id',
'name' => 'required|max:50',
'filter' => 'present|array',
];
}
public function asController(ActionRequest $request): JsonResponse
{
$this->handle($request->validated());
return response()->json('', 201);
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace App\Maildispatcher\Actions;
use App\Maildispatcher\Models\Maildispatcher;
use Illuminate\Http\JsonResponse;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class UpdateAction
{
use AsAction;
/**
* @param array<string, mixed> $input
*/
public function handle(Maildispatcher $maildispatcher, array $input): void
{
$maildispatcher->update([
...$input,
'filter' => (object) $input['filter'],
]);
ResyncAction::dispatch();
}
/**
* @return array<string, string>
*/
public function getValidationAttributes(): array
{
return [
'gateway_id' => 'Verbindung',
];
}
/**
* @return array<string, string>
*/
public function rules(): array
{
return [
'gateway_id' => 'required|exists:mailgateways,id',
'name' => 'required|max:50',
'filter' => 'present|array',
];
}
public function asController(Maildispatcher $maildispatcher, ActionRequest $request): JsonResponse
{
$this->handle($maildispatcher, $request->validated());
return response()->json('', 201);
}
}

View File

@ -0,0 +1,12 @@
<?php
namespace App\Maildispatcher\Data;
use Spatie\LaravelData\Data;
class MailEntry extends Data
{
public function __construct(public string $email)
{
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Maildispatcher\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Localmaildispatcher extends Model
{
use HasFactory;
use HasUuids;
public $guarded = [];
public $timestamps = false;
}

View File

@ -0,0 +1,39 @@
<?php
namespace App\Maildispatcher\Models;
use App\Mailgateway\Models\Mailgateway;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class Maildispatcher extends Model
{
use HasFactory;
use HasUuids;
public $guarded = [];
public $timestamps = false;
public $casts = [
'filter' => 'json',
];
public static function booted(): void
{
static::deleting(function ($dispatcher) {
foreach ($dispatcher->gateway->type->list($dispatcher->name, $dispatcher->gateway->domain) as $email) {
$dispatcher->gateway->type->remove($dispatcher->name, $dispatcher->gateway->domain, $email->email);
}
});
}
/**
* @return BelongsTo<Mailgateway, self>
*/
public function gateway(): BelongsTo
{
return $this->belongsTo(Mailgateway::class);
}
}

View File

@ -0,0 +1,62 @@
<?php
namespace App\Maildispatcher\Resources;
use App\Lib\HasMeta;
use App\Maildispatcher\Models\Maildispatcher;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Resources\MailgatewayResource;
use App\Member\FilterScope;
use App\Member\Member;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Maildispatcher
*/
class MaildispatcherResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'name' => $this->name,
'gateway' => new MailgatewayResource($this->whenLoaded('gateway')),
'gateway_id' => $this->gateway_id,
'filter' => $this->filter,
'id' => $this->id,
'links' => [
'edit' => route('maildispatcher.edit', ['maildispatcher' => $this->getModel()]),
'update' => route('maildispatcher.update', ['maildispatcher' => $this->getModel()]),
'delete' => route('maildispatcher.destroy', ['maildispatcher' => $this->getModel()]),
],
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'links' => [
'create' => route('maildispatcher.create'),
'index' => route('maildispatcher.index'),
],
'default_model' => [
'name' => '',
'gateway_id' => null,
'filter' => FilterScope::from([])->toArray(),
],
'members' => Member::ordered()->get()->map(fn ($member) => ['id' => $member->id, 'name' => $member->fullname]),
'gateways' => Mailgateway::pluck('name', 'id'),
];
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Resources\MailgatewayResource;
use Illuminate\Database\Eloquent\Builder;
use Inertia\Inertia;
use Inertia\Response;
use Lorisleiva\Actions\Concerns\AsAction;
class IndexAction
{
use AsAction;
/**
* @return Builder<Mailgateway>
*/
public function handle(): Builder
{
return (new Mailgateway())->newQuery();
}
public function asController(): Response
{
session()->put('menu', 'setting');
session()->put('title', 'E-Mail-Verbindungen');
return Inertia::render('mailgateway/Index', [
'data' => MailgatewayResource::collection($this->handle()->paginate(10)),
]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class StoreAction
{
use AsAction;
use ValidatesRequests;
/**
* @param array<string, mixed> $input
*/
public function handle(array $input): void
{
$this->checkIfWorks($input);
Mailgateway::create($input);
}
public function asController(ActionRequest $request): void
{
$this->handle($request->validated());
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Models\Mailgateway;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
class UpdateAction
{
use AsAction;
use ValidatesRequests;
/**
* @param array<string, mixed> $input
*/
public function handle(Mailgateway $mailgateway, array $input): void
{
$this->checkIfWorks($input);
$mailgateway->update($input);
}
public function asController(Mailgateway $mailgateway, ActionRequest $request): void
{
$this->handle($mailgateway, $request->validated());
}
}

View File

@ -0,0 +1,65 @@
<?php
namespace App\Mailgateway\Actions;
use App\Mailgateway\Types\Type;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Lorisleiva\Actions\ActionRequest;
trait ValidatesRequests
{
/**
* @param array<string, mixed> $input
*/
public function checkIfWorks(array $input): void
{
if (!app(data_get($input, 'type.cls'))->setParams($input['type']['params'])->works()) {
throw ValidationException::withMessages(['connection' => 'Verbindung fehlgeschlagen.']);
}
}
/**
* @return array<string, mixed>
*/
public function rules(): array
{
return [
'name' => 'required|string|max:255',
'domain' => 'required|string|max:255',
...$this->typeValidation(),
'type.params' => 'present|array',
...collect(request()->input('type.cls')::rules('storeValidator'))->mapWithKeys(fn ($rules, $key) => ["type.params.{$key}" => $rules]),
];
}
/**
* @return array<string, mixed>
*/
public function getValidationAttributes(): array
{
return [
'type.cls' => 'Typ',
'name' => 'Beschreibung',
'domain' => 'Domain',
...collect(request()->input('type.cls')::fieldNames())->mapWithKeys(fn ($attribute, $key) => ["type.params.{$key}" => $attribute]),
];
}
/**
* @return array<string, mixed>
*/
private function typeValidation(): array
{
return [
'type.cls' => ['required', 'string', 'max:255', Rule::in(app('mail-gateways'))],
];
}
public function prepareForValidation(ActionRequest $request): void
{
if (!is_subclass_of(request()->input('type.cls'), Type::class)) {
throw ValidationException::withMessages(['type.cls' => 'Typ ist nicht valide.']);
}
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Mailgateway\Casts;
use App\Mailgateway\Types\Type;
use Illuminate\Contracts\Database\Eloquent\CastsAttributes;
/**
* @implements CastsAttributes<Type, Type>
*/
class TypeCast implements CastsAttributes
{
/**
* Cast the given value.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
*
* @return mixed
*/
public function get($model, string $key, $value, array $attributes)
{
$value = json_decode($value, true);
return app($value['cls'])->setParams($value['params']);
}
/**
* Prepare the given value for storage.
*
* @param \Illuminate\Database\Eloquent\Model $model
* @param mixed $value
* @param array<string, mixed> $attributes
*
* @return mixed
*/
public function set($model, string $key, $value, array $attributes)
{
return json_encode($value);
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Mailgateway;
use App\Mailgateway\Actions\IndexAction;
use App\Setting\Contracts\Indexable;
use App\Setting\LocalSettings;
class MailgatewaySettings extends LocalSettings implements Indexable
{
public static function group(): string
{
return 'mailgateway';
}
public static function slug(): string
{
return 'mailgateway';
}
public static function indexAction(): string
{
return IndexAction::class;
}
public static function title(): string
{
return 'E-Mail-Verbindungen';
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Mailgateway\Models;
use App\Mailgateway\Casts\TypeCast;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Mailgateway extends Model
{
use HasFactory;
use HasUuids;
public $casts = ['type' => TypeCast::class];
public $guarded = [];
}

View File

@ -0,0 +1,68 @@
<?php
namespace App\Mailgateway\Resources;
use App\Lib\HasMeta;
use App\Mailgateway\Models\Mailgateway;
use Illuminate\Http\Resources\Json\JsonResource;
/**
* @mixin Mailgateway
*/
class MailgatewayResource extends JsonResource
{
use HasMeta;
/**
* Transform the resource into an array.
*
* @param \Illuminate\Http\Request $request
*
* @return array<string, mixed>
*/
public function toArray($request)
{
return [
'name' => $this->name,
'domain' => $this->domain,
'type_human' => $this->type::name(),
'works' => $this->type->works(),
'type' => $this->type->toResource(),
'id' => $this->id,
'links' => [
'update' => route('mailgateway.update', ['mailgateway' => $this->getModel()]),
],
];
}
/**
* @return array<string, mixed>
*/
public static function meta(): array
{
return [
'links' => [
'store' => route('mailgateway.store'),
],
'types' => app('mail-gateways')->map(fn ($gateway) => [
'id' => $gateway,
'name' => $gateway::name(),
'fields' => $gateway::presentFields('storeValidator'),
'defaults' => (object) $gateway::defaults(),
])->prepend([
'id' => null,
'name' => '-- kein --',
'fields' => [],
'defaults' => (object) [],
]),
'default' => [
'domain' => '',
'name' => '',
'type' => [
'params' => [],
'cls' => null,
],
],
];
}
}

View File

@ -0,0 +1,60 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use App\Maildispatcher\Models\Localmaildispatcher;
use Illuminate\Support\Collection;
class LocalType extends Type
{
public static function name(): string
{
return 'Lokal';
}
public function works(): bool
{
return true;
}
public static function fields(): array
{
return [];
}
public function setParams(array $params): static
{
return $this;
}
/**
* {@inheritdoc}
*/
public function list(string $name, string $domain): Collection
{
return Localmaildispatcher::where('from', "{$name}@{$domain}")->get()->map(fn ($mail) => MailEntry::from(['email' => $mail->to]));
}
public function search(string $name, string $domain, string $email): ?MailEntry
{
$result = Localmaildispatcher::where('from', "{$name}@{$domain}")->where('to', $email)->first();
return $result ? MailEntry::from([
'email' => $result->to,
]) : null;
}
public function add(string $name, string $domain, string $email): void
{
Localmaildispatcher::create([
'from' => "{$name}@{$domain}",
'to' => $email,
]);
}
public function remove(string $name, string $domain, string $email): void
{
Localmaildispatcher::where('from', "{$name}@{$domain}")->where('to', $email)->delete();
}
}

View File

@ -0,0 +1,108 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use App\Mailman\Exceptions\MailmanServiceException;
use App\Mailman\Support\MailmanService;
use Illuminate\Support\Collection;
class MailmanType extends Type
{
public string $url;
public string $user;
public string $password;
public function setParams(array $params): static
{
$this->url = data_get($params, 'url');
$this->user = data_get($params, 'user');
$this->password = data_get($params, 'password');
return $this;
}
public static function name(): string
{
return 'Mailman';
}
public function works(): bool
{
return $this->service()->check();
}
/**
* {@inheritdoc}
*/
public static function fields(): array
{
return [
[
'name' => 'url',
'label' => 'URL',
'type' => 'text',
'storeValidator' => 'required|max:255',
'updateValidator' => 'required|max:255',
'default' => '',
],
[
'name' => 'user',
'label' => 'Benutzer',
'type' => 'text',
'storeValidator' => 'required|max:255',
'updateValidator' => 'required|max:255',
'default' => '',
],
[
'name' => 'password',
'label' => 'Passwort',
'type' => 'password',
'storeValidator' => 'required|max:255',
'updateValidator' => 'nullable|max:255',
'default' => '',
],
];
}
public function search(string $name, string $domain, string $email): ?MailEntry
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$member = $this->service()->members($list)->first(fn ($member) => $member->email === $email);
return $member ? MailEntry::from(['email' => $member->email]) : null;
}
public function add(string $name, string $domain, string $email): void
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$this->service()->addMember($list, $email);
}
/**
* {@inheritdoc}
*/
public function list(string $name, string $domain): Collection
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
return collect($this->service()->members($list)->map(fn ($member) => MailEntry::from(['email' => $member->email]))->all());
}
public function remove(string $name, string $domain, string $email): void
{
$list = $this->service()->getLists()->first(fn ($list) => $list->fqdnListname === "{$name}@{$domain}");
throw_if(!$list, MailmanServiceException::class, "List for {$name}@{$domain} not found");
$member = $this->service()->members($list)->first(fn ($member) => $member->email === $email);
throw_if(!$member, MailmanServiceException::class, 'Member for removing not found');
$this->service()->removeMember($member);
}
private function service(): MailmanService
{
return app(MailmanService::class)->setCredentials($this->url, $this->user, $this->password);
}
}

View File

@ -0,0 +1,102 @@
<?php
namespace App\Mailgateway\Types;
use App\Maildispatcher\Data\MailEntry;
use Illuminate\Support\Collection;
abstract class Type
{
abstract public static function name(): string;
/**
* @return array<int, MailgatewayCustomField>
*/
abstract public static function fields(): array;
abstract public function works(): bool;
abstract public function search(string $name, string $domain, string $email): ?MailEntry;
abstract public function add(string $name, string $domain, string $email): void;
abstract public function remove(string $name, string $domain, string $email): void;
/**
* @return Collection<int, MailEntry>
*/
abstract public function list(string $name, string $domain): Collection;
/**
* @param array<string, mixed> $params
*/
abstract public function setParams(array $params): static;
/**
* @return array<string, string>
*/
public static function defaults(): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [
$field['name'] => $field['default'],
])->toArray();
}
/**
* @return array<int, MailgatewayParsedCustomField>
*/
public static function presentFields(string $validator): array
{
return array_map(fn ($field) => [
...$field,
'is_required' => str_contains($field[$validator], 'required'),
], static::fields());
}
/**
* @return array<string, mixed>
*/
public static function rules(string $validator): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [
$field['name'] => $field[$validator],
])->toArray();
}
/**
* @return array<string, array<string, mixed>>
*/
public function toResource(): array
{
return [
'cls' => get_class($this),
'params' => get_object_vars($this),
];
}
/**
* @param Collection<int, MailEntry> $results
*/
public function sync(string $name, string $domain, Collection $results): void
{
foreach ($results as $result) {
if ($this->search($name, $domain, $result->email)) {
continue;
}
$this->add($name, $domain, $result->email);
}
$this->list($name, $domain)
->filter(fn ($listEntry) => null === $results->first(fn ($r) => $r->email === $listEntry->email))
->each(fn ($listEntry) => $this->remove($name, $domain, $listEntry->email));
}
/**
* @return array<string, string>
*/
public static function fieldNames(): array
{
return collect(static::fields())->mapWithKeys(fn ($field) => [$field['name'] => $field['label']])->toArray();
}
}

View File

@ -6,6 +6,7 @@ use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\MapOutputName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
use Tests\RequestFactories\MailinglistFactory;
#[MapName(SnakeCaseMapper::class)]
class MailingList extends Data
@ -24,4 +25,9 @@ class MailingList extends Data
public int $volume,
) {
}
public static function factory(): MailinglistFactory
{
return MailinglistFactory::new();
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace App\Mailman\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class Member extends Data
{
public function __construct(
public string $email,
public string $memberId,
) {
}
}

View File

@ -4,9 +4,11 @@ namespace App\Mailman;
use App\Mailman\Actions\SettingIndexAction;
use App\Mailman\Actions\SettingSaveAction;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use App\Setting\LocalSettings;
class MailmanSettings extends LocalSettings
class MailmanSettings extends LocalSettings implements Storeable, Indexable
{
public ?string $base_url;
@ -39,7 +41,7 @@ class MailmanSettings extends LocalSettings
return SettingIndexAction::class;
}
public static function saveAction(): string
public static function storeAction(): string
{
return SettingSaveAction::class;
}

View File

@ -3,6 +3,7 @@
namespace App\Mailman\Support;
use App\Mailman\Data\MailingList;
use App\Mailman\Data\Member;
use App\Mailman\Exceptions\MailmanServiceException;
use App\Mailman\MailmanSettings;
use Illuminate\Http\Client\ConnectionException;
@ -43,26 +44,50 @@ class MailmanService
}
/**
* @return LazyCollection<int, string>
* @return LazyCollection<int, Member>
*/
public function members(string $listId): LazyCollection
public function members(MailingList $list): LazyCollection
{
return app(Paginator::class)->result(
fn ($page) => $this->http()->get("/lists/{$listId}/roster/member?page={$page}&count=10"),
function ($response) use ($listId) {
throw_unless($response->ok(), MailmanServiceException::class, 'Fetching members for listId '.$listId.' failed.');
/** @var array<int, array{email: string}>|null */
$entries = data_get($response->json(), 'entries');
fn ($page) => $this->http()->get("/lists/{$list->listId}/roster/member?page={$page}&count=10"),
function ($response) use ($list) {
throw_unless($response->ok(), MailmanServiceException::class, 'Fetching members for listId '.$list->listId.' failed.');
/** @var array<int, array{email: string, self_link: string}>|null */
$entries = data_get($response->json(), 'entries', []);
throw_if(is_null($entries), MailmanServiceException::class, 'Failed getting member list from response');
foreach ($entries as $entry) {
yield $entry['email'];
yield Member::from([
...$entry,
'member_id' => strrev(preg_split('/\//', strrev($entry['self_link']))[0]),
]);
}
},
fn ($response) => data_get($response->json(), 'total_size')
);
}
public function addMember(MailingList $list, string $email): void
{
$response = $this->http()->post('members', [
'list_id' => $list->listId,
'subscriber' => $email,
'pre_verified' => 'true',
'pre_approved' => 'true',
'send_welcome_message' => 'false',
'pre_confirmed' => 'true',
]);
throw_unless(201 === $response->status(), MailmanServiceException::class, 'Adding member '.$email.' to '.$list->listId.' failed');
}
public function removeMember(Member $member): void
{
$response = $this->http()->delete("members/{$member->memberId}");
throw_unless(204 === $response->status(), MailmanServiceException::class, 'Removing member failed');
}
private function http(): PendingRequest
{
return Http::withBasicAuth($this->username, $this->password)->withOptions(['base_uri' => $this->baseUrl]);

View File

@ -2,10 +2,11 @@
namespace App\Member\Actions;
use App\Member\FilterScope;
use App\Member\Member;
use App\Member\MemberResource;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
use Illuminate\Pagination\LengthAwarePaginator;
use Lorisleiva\Actions\ActionRequest;
use Lorisleiva\Actions\Concerns\AsAction;
@ -14,27 +15,18 @@ class SearchAction
use AsAction;
/**
* @return Collection<int, Member>
* @return LengthAwarePaginator<int, Member>
*/
public function handle(string $search): Collection
public function handle(FilterScope $filter): LengthAwarePaginator
{
return Member::search($search)->query(fn ($query) => $query->ordered())->get();
return Member::search($filter->search)->query(fn ($q) => $q->select('*')
->withFilter($filter)
->ordered()
)->paginate(15);
}
public function asController(ActionRequest $request): AnonymousResourceCollection
{
if (null !== $request->input('minLength') && strlen($request->input('search', '')) < $request->input('minLength')) {
return MemberResource::collection($this->empty());
}
return MemberResource::collection($this->handle($request->input('search', '')));
}
/**
* @return Collection<int, Member>
*/
private function empty(): Collection
{
return Member::where('id', -1)->get();
return MemberResource::collection($this->handle(FilterScope::fromRequest($request->input('filter', ''))));
}
}

View File

@ -19,14 +19,17 @@ class FilterScope extends Filter
/**
* @param array<int, int> $activityIds
* @param array<int, int> $subactivityIds
* @param array<int, int> $groupIds
* @param array<int, int> $additional
*/
public function __construct(
public bool $ausstand = false,
public ?string $billKind = null,
public array $activityIds = [],
public array $subactivityIds = [],
public string $search = '',
public ?int $groupId = null,
public ?string $search = '',
public array $groupIds = [],
public array $additional = [],
) {
}
@ -45,29 +48,35 @@ class FilterScope extends Filter
*/
public function apply(Builder $query): Builder
{
if ($this->ausstand) {
$query->whereAusstand();
}
$query->orWhere(function ($query) {
if ($this->ausstand) {
$query->whereAusstand();
}
if ($this->billKind) {
$query->where('bill_kind', BillKind::fromValue($this->billKind));
}
if ($this->billKind) {
$query->where('bill_kind', BillKind::fromValue($this->billKind));
}
if ($this->groupId) {
$query->where('group_id', $this->groupId);
}
if (count($this->groupIds)) {
$query->whereIn('group_id', $this->groupIds);
}
if (count($this->subactivityIds) + count($this->activityIds) > 0) {
$query->whereHas('memberships', function ($q) {
$q->active();
if (count($this->subactivityIds)) {
$q->whereIn('subactivity_id', $this->subactivityIds);
}
if (count($this->activityIds)) {
$q->whereIn('activity_id', $this->activityIds);
}
});
}
if (count($this->subactivityIds) + count($this->activityIds) > 0) {
$query->whereHas('memberships', function ($q) {
$q->active();
if (count($this->subactivityIds)) {
$q->whereIn('subactivity_id', $this->subactivityIds);
}
if (count($this->activityIds)) {
$q->whereIn('activity_id', $this->activityIds);
}
});
}
})->orWhere(function ($query) {
if (count($this->additional)) {
$query->whereIn('id', $this->additional);
}
});
return $query;
}

View File

@ -4,6 +4,7 @@ namespace App\Member;
use App\Country;
use App\Http\Controllers\Controller;
use App\Maildispatcher\Actions\ResyncAction;
use App\Setting\GeneralSettings;
use App\Setting\NamiSettings;
use Illuminate\Http\RedirectResponse;
@ -86,6 +87,7 @@ class MemberController extends Controller
}
$member->delete();
ResyncAction::dispatch();
return redirect()->back();
}

View File

@ -5,6 +5,7 @@ namespace App\Member;
use App\Activity;
use App\Group;
use App\Invoice\BillKind;
use App\Maildispatcher\Actions\ResyncAction;
use App\Member\Actions\NamiPutMemberAction;
use App\Setting\NamiSettings;
use App\Subactivity;
@ -99,6 +100,7 @@ class MemberRequest extends FormRequest
Subactivity::find($this->input('first_subactivity_id')),
);
}
ResyncAction::dispatch();
}
public function persistUpdate(Member $member): void
@ -118,5 +120,6 @@ class MemberRequest extends FormRequest
if (!$this->input('has_nami') && null !== $member->nami_id) {
DeleteJob::dispatch($member->nami_id);
}
ResyncAction::dispatch();
}
}

View File

@ -140,6 +140,7 @@ class MemberResource extends JsonResource
'genders' => Gender::pluck('name', 'id'),
'billKinds' => BillKind::forSelect(),
'nationalities' => Nationality::pluck('name', 'id'),
'members' => Member::ordered()->get()->map(fn ($member) => ['id' => $member->id, 'name' => $member->fullname]),
'links' => [
'index' => route('member.index'),
'create' => route('member.create'),

View File

@ -2,6 +2,8 @@
namespace App\Providers;
use App\Mailgateway\Types\LocalType;
use App\Mailgateway\Types\MailmanType;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Support\ServiceProvider;
@ -26,6 +28,11 @@ class AppServiceProvider extends ServiceProvider
return $this;
});
app()->bind('mail-gateways', fn () => collect([
LocalType::class,
MailmanType::class,
]));
}
/**

View File

@ -0,0 +1,11 @@
<?php
namespace App\Setting\Contracts;
interface Indexable
{
/**
* @return class-string
*/
public static function indexAction(): string;
}

View File

@ -0,0 +1,11 @@
<?php
namespace App\Setting\Contracts;
interface Storeable
{
/**
* @return class-string
*/
public static function storeAction(): string;
}

View File

@ -14,16 +14,6 @@ class GeneralSettings extends Settings
/** @var array<int, int> */
public array $allowed_nami_accounts;
/**
* @return array<int, string>
*/
public function moduleOptions(): array
{
return [
'bill',
];
}
public static function group(): string
{
return 'general';

View File

@ -8,16 +8,6 @@ abstract class LocalSettings extends Settings
{
abstract public static function slug(): string;
/**
* @return class-string
*/
abstract public static function indexAction(): string;
/**
* @return class-string
*/
abstract public static function saveAction(): string;
abstract public static function title(): string;
public static function url(): string

View File

@ -2,6 +2,8 @@
namespace App\Setting;
use App\Setting\Contracts\Indexable;
use App\Setting\Contracts\Storeable;
use Illuminate\Routing\Router;
class SettingFactory
@ -12,14 +14,19 @@ class SettingFactory
private array $settings = [];
/**
* @param class-string<LocalSettings> $setting
* @param class-string $setting
*/
public function register(string $setting): void
{
$this->settings[] = $setting;
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->get($setting::url(), $setting::indexAction());
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->post($setting::url(), $setting::saveAction());
if (new $setting() instanceof Indexable) {
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->get($setting::url(), $setting::indexAction());
}
if (new $setting() instanceof Storeable) {
app(Router::class)->middleware(['web', 'auth:web', SettingMiddleware::class])->post($setting::url(), $setting::storeAction());
}
if (1 === count($this->settings)) {
app(Router::class)->redirect('/setting', '/setting/'.$setting::slug());
@ -33,7 +40,7 @@ class SettingFactory
{
return collect($this->settings)->map(fn ($setting) => [
'url' => $setting::url(),
'is_active' => request()->fullUrlIs(url($setting::url())),
'is_active' => '/'.request()->path() === $setting::url(),
'title' => $setting::title(),
])
->toArray();

View File

@ -3,6 +3,7 @@
namespace App\Setting;
use App\Invoice\InvoiceSettings;
use App\Mailgateway\MailgatewaySettings;
use App\Mailman\MailmanSettings;
use Illuminate\Support\ServiceProvider;
@ -27,5 +28,6 @@ class SettingServiceProvider extends ServiceProvider
{
app(SettingFactory::class)->register(InvoiceSettings::class);
app(SettingFactory::class)->register(MailmanSettings::class);
app(SettingFactory::class)->register(MailgatewaySettings::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace Database\Factories\Maildispatcher\Models;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Maildispatcher\Models\Maildispatcher>
*/
class MaildispatcherFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
//
];
}
}

View File

@ -0,0 +1,54 @@
<?php
namespace Database\Factories\Mailgateway\Models;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Types\Type;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Mailgateway\Models\Mailgateway>
*/
class MailgatewayFactory extends Factory
{
protected $model = Mailgateway::class;
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition()
{
return [
'name' => $this->faker->words(5, true),
'type' => [
'cls' => app('mail-gateways')->random(),
'params' => [],
],
'domain' => $this->faker->safeEmailDomain(),
];
}
/**
* @param class-string<Type> $type
* @param array<string, mixed> $params
*/
public function type(string $type, array $params): self
{
return $this->state(['type' => [
'cls' => $type,
'params' => $params,
]]);
}
public function name(string $name): self
{
return $this->state(['name' => $name]);
}
public function domain(string $domain): self
{
return $this->state(['domain' => $domain]);
}
}

View File

@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('mailgateways', function (Blueprint $table) {
$table->uuid('id');
$table->string('name');
$table->string('domain');
$table->json('type');
$table->timestamps();
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('mailgateways');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('maildispatchers', function (Blueprint $table) {
$table->uuid('id');
$table->string('name');
$table->uuid('gateway_id');
$table->json('filter');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('maildispatchers');
}
};

View File

@ -0,0 +1,32 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class() extends Migration {
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('localmaildispatchers', function (Blueprint $table) {
$table->uuid('id');
$table->string('from');
$table->string('to');
$table->unique(['from', 'to']);
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::dropIfExists('localmaildispatchers');
}
};

View File

@ -18,6 +18,8 @@ parameters:
ContributionMemberData: 'array<string, mixed>'
ContributionRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, members: array<int, int>}'
ContributionApiRequestArray: 'array{dateFrom: string, dateUntil: string, zipLocation: string, country: int, eventName: string, member_data: array<int, ContributionMemberData>}'
MailgatewayCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string}'
MailgatewayParsedCustomField: 'array{name: string, label: string, type: string, storeValidator: string, updateValidator: string, default: string, is_required: bool}'
ignoreErrors:
-
@ -633,3 +635,43 @@ parameters:
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<int,class\\-string\\<App\\\\Setting\\\\LocalSettings\\>\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1
path: app/Setting/SettingFactory.php
-
message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$email\\.$#"
count: 2
path: app/Maildispatcher/Actions/ResyncAction.php
-
message: "#^Access to an undefined property Illuminate\\\\Database\\\\Eloquent\\\\Model\\:\\:\\$email_parents\\.$#"
count: 2
path: app/Maildispatcher/Actions/ResyncAction.php
-
message: "#^Unable to resolve the template type TKey in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/StoreAction.php
-
message: "#^Unable to resolve the template type TValue in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/StoreAction.php
-
message: "#^Unable to resolve the template type TKey in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/UpdateAction.php
-
message: "#^Unable to resolve the template type TValue in call to function collect$#"
count: 2
path: app/Mailgateway/Actions/UpdateAction.php
-
message: "#^Return type of call to method Illuminate\\\\Support\\\\Collection\\<\\(int\\|string\\),mixed\\>\\:\\:map\\(\\) contains unresolvable type\\.$#"
count: 1
path: app/Mailgateway/Resources/MailgatewayResource.php
-
message: "#^Generic type Illuminate\\\\Pagination\\\\LengthAwarePaginator\\<int, App\\\\Member\\\\Member\\> in PHPDoc tag @return specifies 2 template types, but class Illuminate\\\\Pagination\\\\LengthAwarePaginator supports only 1\\: TValue$#"
count: 1
path: app/Member/Actions/SearchAction.php

View File

@ -33,6 +33,9 @@
}
&.btn-danger {
@apply bg-red-400 text-red-100 hover:bg-red-300;
&:not(.disabled):hover {
@apply bg-red-500 text-red-100;
}
}
&.label {

52
resources/img/svg/at.svg Normal file
View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="iso-8859-1"?>
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Capa_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 512 512" style="enable-background:new 0 0 512 512;" xml:space="preserve">
<g>
<g>
<path d="M257,210c-24.814,0-45,20.186-45,45c0,24.814,20.186,45,45,45c24.814,0,45-20.186,45-45C302,230.186,281.814,210,257,210z
"/>
</g>
</g>
<g>
<g>
<path d="M255,0C114.39,0,0,114.39,0,255s114.39,257,255,257s257-116.39,257-257S395.61,0,255,0z M362,330
c-20.273,0-38.152-10.161-49.017-25.596C299.23,319.971,279.354,330,257,330c-41.353,0-75-33.647-75-75c0-41.353,33.647-75,75-75
c16.948,0,32.426,5.865,45,15.383V195c0-8.291,6.709-15,15-15c8.291,0,15,6.709,15,15c0,33.36,0,41.625,0,75
c0,16.538,13.462,30,30,30c16.538,0,30-13.462,30-30c0-100.391-66.432-150-135-150c-74.443,0-135,60.557-135,135
s60.557,135,135,135c30,0,58.374-9.609,82.061-27.803c15.822-12.078,33.94,11.765,18.281,23.789
C328.353,408.237,293.665,420,257,420c-90.981,0-165-74.019-165-165S166.019,90,257,90c82.897,0,165,61.135,165,180
C422,303.091,395.091,330,362,330z"/>
</g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
<g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@ -10,7 +10,7 @@
class="flex items-center border-gray-600 text-gray-300 leading-none border-solid bg-gray-700 w-full appearance-none outline-none rounded-lg size-sm text-xs px-1 border pr-6"
v-text="`${value.length} Einträge ausgewählt`"
></div>
<div v-show="visible" class="absolute shadow-lg bg-gray-600 border border-gray-500 rounded-lg p-2 top-7">
<div v-show="visible" class="absolute w-[max-content] z-30 max-h-[31rem] overflow-auto shadow-lg bg-gray-600 border border-gray-500 rounded-lg p-2 top-7">
<div v-for="(option, index) in parsedOptions" class="flex items-center space-x-2" :key="index">
<f-switch :id="`${id}-${index}`" size="sm" :items="value.includes(option.id)" :value="option.id" @input="trigger(option, $event)"></f-switch>
<div class="text-sm text-gray-200" v-text="option.name"></div>

View File

@ -8,12 +8,7 @@
<select :disabled="disabled" :name="name" :value="value" @change="trigger">
<option v-if="placeholder" v-html="placeholder" :value="null"></option>
<option
v-for="option in parsedOptions"
:key="option.id"
v-html="option.name"
:value="option.id"
></option>
<option v-for="option in parsedOptions" :key="option.id" v-html="option.name" :value="option.id"></option>
</select>
<div class="info-wrap">
<div v-if="hint" v-tooltip="hint">
@ -95,10 +90,7 @@ export default {
},
methods: {
trigger(v) {
this.$emit(
'input',
isNaN(parseInt(v.target.value)) ? (v.target.value ? v.target.value : null) : parseInt(v.target.value)
);
this.$emit('input', /^[0-9]+$/.test(v.target.value) ? parseInt(v.target.value) : v.target.value ? v.target.value : null);
},
clear() {
this.$emit('input', null);
@ -119,12 +111,6 @@ export default {
<style scope>
.inset-bg {
background: linear-gradient(
to bottom,
hsl(247.5, 66.7%, 97.6%) 0%,
hsl(247.5, 66.7%, 97.6%) 41%,
hsl(0deg 0% 100%) 41%,
hsl(180deg 0% 100%) 100%
);
background: linear-gradient(to bottom, hsl(247.5, 66.7%, 97.6%) 0%, hsl(247.5, 66.7%, 97.6%) 41%, hsl(0deg 0% 100%) 41%, hsl(180deg 0% 100%) 100%);
}
</style>

View File

@ -1,17 +1,7 @@
<template>
<div class="flex gap-1 justify-center items-center">
<svg-sprite
class="flex-none"
v-if="member.is_leader"
:class="[ageColors.leiter, iconClass]"
src="lilie"
></svg-sprite>
<svg-sprite
class="flex-none"
v-if="member.age_group_icon"
:class="[ageColors[member.age_group_icon], iconClass]"
src="lilie"
></svg-sprite>
<svg-sprite class="flex-none" v-if="member.is_leader" :class="[ageColors.leiter, iconClass]" src="lilie"></svg-sprite>
<svg-sprite class="flex-none" v-if="member.age_group_icon" :class="[ageColors[member.age_group_icon], iconClass]" src="lilie"></svg-sprite>
</div>
</template>

View File

@ -1,9 +1,9 @@
<template>
<button class="btn btn-primary relative group">
<button v-on="$listeners" class="btn btn-primary relative group">
<div :class="{hidden: !isLoading, flex: isLoading}" class="absolute items-center top-0 h-full left-0 ml-2">
<ui-spinner class="border-primary-400 w-6 h-6 group-hover:border-primary-200"></ui-spinner>
</div>
Weiter
<slot></slot>
</button>
</template>

View File

@ -16,6 +16,7 @@
<v-link href="/subscription" v-show="hasModule('bill')" menu="subscription" icon="money">Beiträge</v-link>
<v-link href="/contribution" menu="contribution" icon="contribution">Zuschüsse</v-link>
<v-link href="/activity" menu="activity" icon="activity">Tätigkeiten</v-link>
<v-link href="/maildispatcher" menu="maildispatcher" icon="at">Mail-Verteiler</v-link>
</div>
<div class="grid gap-2">
<v-link href="/setting" menu="setting" icon="setting">Einstellungen</v-link>

View File

@ -9,10 +9,13 @@ export default {
},
computed: {
filterString() {
return btoa(encodeURIComponent(JSON.stringify(this.inner.meta.filter)));
return this.toFilterString(this.inner.meta.filter);
},
},
methods: {
toFilterString(data) {
return btoa(encodeURIComponent(JSON.stringify(data)));
},
reload(resetPage = true) {
var _self = this;
var data = {

View File

@ -83,7 +83,11 @@ export default {
set: debounce(async function (event) {
this.search.s = event;
var response = await this.axios.post('/api/member/search', {search: event, minLength: 3});
var response = await this.axios.post('/api/member/search', {
filter: {
search: event,
},
});
this.search.results = response.data.data;
}, 300),

View File

@ -0,0 +1,121 @@
<template>
<page-layout>
<template #toolbar>
<page-toolbar-button :href="meta.links.index" color="primary" icon="undo">Zurück</page-toolbar-button>
</template>
<form id="form" class="p-3 grid gap-3" @submit.prevent="submit">
<f-save-button form="form"></f-save-button>
<ui-box heading="Metadatem">
<div class="grid gap-4 sm:grid-cols-2">
<f-text id="name" name="name" v-model="model.name" label="Name" size="sm" required></f-text>
<f-select id="gateway_id" name="gateway_id" :options="meta.gateways" v-model="model.gateway_id" label="Verbindung" size="sm" required></f-select>
</div>
</ui-box>
<ui-box heading="Filterregeln" v-if="members !== null">
<div class="grid gap-4 sm:grid-cols-2">
<f-multipleselect
id="activity_ids"
name="activity_ids"
:options="members.meta.filterActivities"
v-model="model.filter.activity_ids"
@input="reload(1)"
label="Tätigkeit"
size="sm"
></f-multipleselect>
<f-multipleselect
id="subactivity_ids"
name="subactivity_ids"
:options="members.meta.filterSubactivities"
v-model="model.filter.subactivity_ids"
@input="reload(1)"
label="Unterttätigkeit"
size="sm"
></f-multipleselect>
<f-multipleselect
id="additional"
name="additional"
:options="members.meta.members"
v-model="model.filter.additional"
@input="reload(1)"
label="Zusätzliche Mitglieder"
size="sm"
></f-multipleselect>
<f-multipleselect
id="groupIds"
name="groupIds"
:options="members.meta.groups"
v-model="model.filter.group_ids"
@input="reload(1)"
label="Gruppierungen"
size="sm"
></f-multipleselect>
</div>
</ui-box>
<ui-box heading="Mitglieder" v-if="members !== null">
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead>
<th></th>
<th>Nachname</th>
<th>Vorname</th>
<th>E-Mail-Adresse</th>
<th>E-Mail-Adresse Eltern</th>
</thead>
<tr v-for="(member, index) in members.data" :key="index">
<td><ui-age-groups :member="member"></ui-age-groups></td>
<td v-text="member.lastname"></td>
<td v-text="member.firstname"></td>
<td v-text="member.email"></td>
<td v-text="member.email_parents"></td>
</tr>
</table>
<ui-pagination class="mt-4" @reload="reload" :value="members.meta" :only="['data']"></ui-pagination>
</ui-box>
</form>
</page-layout>
</template>
<script>
import indexHelpers from '../../mixins/indexHelpers.js';
import hasFlash from '../../mixins/hasFlash.js';
export default {
mixins: [indexHelpers, hasFlash],
data: function () {
return {
model: this.data === undefined ? {...this.meta.default_model} : {...this.data},
members: null,
};
},
props: {
data: {},
meta: {},
},
methods: {
async reload(page) {
this.members = (
await this.axios.post('/api/member/search', {
page: page || 1,
filter: this.toFilterString(this.model.filter),
})
).data;
},
async submit() {
try {
this.model.id ? await this.axios.patch(this.model.links.update, this.model) : await this.axios.post('/maildispatcher', this.model);
this.$inertia.visit(this.meta.links.index);
} catch (e) {
this.errorsFromException(e);
}
},
},
async created() {
this.reload();
},
};
</script>

View File

@ -0,0 +1,67 @@
<template>
<page-layout>
<template #toolbar>
<page-toolbar-button :href="data.meta.links.create" color="primary" icon="plus">Verteiler erstellen</page-toolbar-button>
</template>
<ui-popup heading="Verteiler löschen?" v-if="deleting !== null" @close="deleting.reject()">
<div>
<p class="mt-4">Den Verteiler "{{ deleting.dispatcher.name }}" löschen?</p>
<div class="grid grid-cols-2 gap-3 mt-6">
<a href="#" @click.prevent="deleting.resolve()" class="text-center btn btn-danger">Löschen</a>
<a href="#" @click.prevent="deleting.reject()" class="text-center btn btn-primary">Abbrechen</a>
</div>
</div>
</ui-popup>
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm">
<thead>
<th>Name</th>
<th>Domain</th>
<th>Verbindung</th>
<th></th>
</thead>
<tr v-for="(dispatcher, index) in data.data" :key="index">
<td>
<div v-text="dispatcher.name"></div>
</td>
<td>
<div v-text="dispatcher.gateway.domain"></div>
</td>
<td>
<div v-text="dispatcher.gateway.name"></div>
</td>
<td>
<i-link :href="dispatcher.links.edit" class="mr-1 inline-flex btn btn-warning btn-sm"><svg-sprite src="pencil"></svg-sprite></i-link>
<i-link @click.prevent="remove(dispatcher)" class="inline-flex btn btn-danger btn-sm"><svg-sprite src="trash"></svg-sprite></i-link>
</td>
</tr>
</table>
</page-layout>
</template>
<script>
export default {
data: function () {
return {
deleting: null,
};
},
methods: {
async remove(dispatcher) {
new Promise((resolve, reject) => {
this.deleting = {resolve, reject, dispatcher};
})
.then(() => {
this.$inertia.delete(dispatcher.links.delete);
this.deleting = null;
})
.catch(() => {
this.deleting = null;
});
},
},
props: {
data: {},
},
};
</script>

View File

@ -0,0 +1,117 @@
<template>
<page-layout>
<template #toolbar>
<page-toolbar-button @click.prevent="model = {...data.meta.default}" color="primary" icon="plus">Neue Verbindung</page-toolbar-button>
</template>
<ui-popup :heading="model.id ? 'Verbindung bearbeiten' : 'Neue Verbindung'" v-if="model !== null" @close="model = null">
<form @submit.prevent="submit">
<section class="grid grid-cols-2 gap-3 mt-6">
<f-text v-model="model.name" name="name" id="name" label="Bezeichnung" required></f-text>
<f-text v-model="model.domain" name="domain" id="domain" label="Domain" required></f-text>
<f-select
:value="model.type.cls"
@input="
model.type = {
cls: $event,
params: {...getType($event).defaults},
}
"
label="Typ"
name="type"
id="type"
:options="data.meta.types"
:placeholder="''"
required
></f-select>
<template v-for="(field, index) in getType(model.type.cls).fields">
<f-text
:key="index"
v-if="field.type === 'text' || field.type === 'password'"
:label="field.label"
:type="field.type"
:name="field.name"
:id="field.name"
v-model="model.type.params[field.name]"
:required="field.is_required"
></f-text>
</template>
</section>
<section class="flex mt-4 space-x-2">
<ui-button type="submit" class="btn-danger">Speichern</ui-button>
<ui-button @click.prevent="model = null" class="btn-primary">Abbrechen</ui-button>
</section>
</form>
</ui-popup>
<setting-layout>
<div class="w-full h-full pb-6">
<table cellspacing="0" cellpadding="0" border="0" class="custom-table custom-table-sm hidden md:table">
<thead>
<th>Bezeichnung</th>
<th>Domain</th>
<th>Typ</th>
<th>Prüfung</th>
<th>Aktion</th>
</thead>
<tr v-for="(gateway, index) in inner.data" :key="index">
<td v-text="gateway.name"></td>
<td v-text="gateway.domain"></td>
<td v-text="gateway.type_human"></td>
<td>
<ui-boolean-display
:value="gateway.works"
long-label="Verbindungsstatus"
:label="gateway.works ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'"
></ui-boolean-display>
</td>
<td>
<a href="#" v-tooltip="`Bearbeiten`" @click.prevent="model = {...gateway}" class="inline-flex btn btn-warning btn-sm"><svg-sprite src="pencil"></svg-sprite></a>
</td>
</tr>
</table>
<div class="px-6">
<ui-pagination class="mt-4" :value="data.meta" :only="['data']"></ui-pagination>
</div>
</div>
</setting-layout>
</page-layout>
</template>
<script>
import SettingLayout from '../setting/Layout.vue';
import indexHelpers from '../../mixins/indexHelpers.js';
export default {
mixins: [indexHelpers],
data: function () {
return {
model: null,
inner: {...this.data},
};
},
props: {
data: {},
},
methods: {
getType(type) {
return this.data.meta.types.find((t) => t.id === type);
},
async submit() {
try {
await this.axios[this.model.id ? 'patch' : 'post'](this.model.id ? this.model.links.update : this.data.meta.links.store, this.model);
this.reload();
this.model = null;
} catch (e) {
this.errorsFromException(e);
}
},
},
components: {
SettingLayout,
},
};
</script>

View File

@ -22,7 +22,15 @@
<div class="px-6 py-2 flex border-b border-gray-600 items-center space-x-3">
<f-text :value="getFilter('search')" @input="setFilter('search', $event)" id="search" name="search" label="Suchen …" size="sm"></f-text>
<f-switch v-show="hasModule('bill')" id="ausstand" @input="setFilter('ausstand', $event)" :items="getFilter('ausstand')" label="Nur Ausstände" size="sm"></f-switch>
<f-select id="group_id" @input="setFilter('group_id', $event)" :options="data.meta.groups" :value="getFilter('group_id')" label="Gruppierung" size="sm" name="group_id"></f-select>
<f-multipleselect
id="group_ids"
@input="setFilter('group_ids', $event)"
:options="data.meta.groups"
:value="getFilter('group_ids')"
label="Gruppierungen"
size="sm"
name="group_ids"
></f-multipleselect>
<f-select
v-show="hasModule('bill')"
name="billKinds"
@ -71,7 +79,7 @@
</thead>
<tr v-for="(member, index) in inner.data" :key="index">
<td><age-groups :member="member"></age-groups></td>
<td><ui-age-groups :member="member"></ui-age-groups></td>
<td v-text="member.lastname"></td>
<td v-text="member.firstname"></td>
<td class="hidden 2xl:table-cell" v-text="member.full_address"></td>
@ -92,7 +100,7 @@
<div class="md:hidden p-3 grid gap-3">
<ui-box class="relative" :heading="member.fullname" v-for="(member, index) in data.data" :key="index">
<div slot="in-title">
<age-groups class="ml-2" :member="member" icon-class="w-4 h-4"></age-groups>
<ui-age-groups class="ml-2" :member="member" icon-class="w-4 h-4"></ui-age-groups>
</div>
<div class="text-xs text-gray-200" v-text="member.full_address"></div>
<div class="flex items-center mt-1 space-x-4">
@ -153,9 +161,8 @@ export default {
MemberMemberships,
MemberPayments,
MemberCourses,
'age-groups': () => import('./AgeGroups.vue'),
'tags': () => import('./Tags.vue'),
'actions': () => import('./index/Actions.vue'),
tags: () => import('./Tags.vue'),
actions: () => import('./index/Actions.vue'),
},
methods: {

View File

@ -1,31 +1,32 @@
<template>
<form id="billsettingform" class="grow p-6 grid grid-cols-2 gap-3 items-start content-start" @submit.prevent="submit">
<f-save-button form="billsettingform"></f-save-button>
<f-text label="Absender" hint="Absender-Name in Kurzform, i.d.R. der kurze Stammesname" name="from" id="from" v-model="inner.from"></f-text>
<f-text label="Absender (lang)" v-model="inner.from_long" name="from_long" id="from_long" hint="Absender-Name in Langform, i.d.R. der Stammesname"></f-text>
<h2 class="text-lg font-semibold text-gray-300 col-span-2 mt-5">Kontaktdaten</h2>
<div class="col-span-2 text-gray-300 text-sm">Diese Kontaktdaten stehen im Absender-Bereich auf der Rechnung.</div>
<f-text label="Straße" v-model="inner.address" name="address" id="address"></f-text>
<f-text label="PLZ" v-model="inner.zip" name="zip" id="zip"></f-text>
<f-text label="Ort" v-model="inner.place" name="place" id="place"></f-text>
<f-text label="E-Mail-Adresse" v-model="inner.email" name="email" id="email"></f-text>
<f-text label="Telefonnummer" v-model="inner.mobile" name="mobile" id="mobile"></f-text>
<f-text label="Webseite" v-model="inner.website" name="website" id="website"></f-text>
<f-text label="IBAN" v-model="inner.iban" name="iban" id="iban"></f-text>
<f-text label="BIC" v-model="inner.bic" name="bic" id="bic"></f-text>
</form>
<page-layout>
<setting-layout>
<form id="billsettingform" class="grow p-6 grid grid-cols-2 gap-3 items-start content-start" @submit.prevent="submit">
<f-save-button form="billsettingform"></f-save-button>
<f-text label="Absender" hint="Absender-Name in Kurzform, i.d.R. der kurze Stammesname" name="from" id="from" v-model="inner.from"></f-text>
<f-text label="Absender (lang)" v-model="inner.from_long" name="from_long" id="from_long" hint="Absender-Name in Langform, i.d.R. der Stammesname"></f-text>
<h2 class="text-lg font-semibold text-gray-300 col-span-2 mt-5">Kontaktdaten</h2>
<div class="col-span-2 text-gray-300 text-sm">Diese Kontaktdaten stehen im Absender-Bereich auf der Rechnung.</div>
<f-text label="Straße" v-model="inner.address" name="address" id="address"></f-text>
<f-text label="PLZ" v-model="inner.zip" name="zip" id="zip"></f-text>
<f-text label="Ort" v-model="inner.place" name="place" id="place"></f-text>
<f-text label="E-Mail-Adresse" v-model="inner.email" name="email" id="email"></f-text>
<f-text label="Telefonnummer" v-model="inner.mobile" name="mobile" id="mobile"></f-text>
<f-text label="Webseite" v-model="inner.website" name="website" id="website"></f-text>
<f-text label="IBAN" v-model="inner.iban" name="iban" id="iban"></f-text>
<f-text label="BIC" v-model="inner.bic" name="bic" id="bic"></f-text>
</form>
</setting-layout>
</page-layout>
</template>
<script>
import AppLayout from '../../layouts/AppLayout.vue';
import SettingLayout from './Layout.vue';
export default {
layout: [AppLayout, SettingLayout],
data: function () {
return {
inner: {},
inner: {...this.data},
};
},
props: {
@ -36,8 +37,8 @@ export default {
this.$inertia.post('/setting/bill', this.inner);
},
},
created() {
this.inner = this.data;
components: {
SettingLayout,
},
};
</script>

View File

@ -1,17 +1,15 @@
<template>
<page-layout>
<div class="flex grow relative">
<ui-tabs v-model="active" :entries="$page.props.setting_menu"></ui-tabs>
<slot></slot>
</div>
</page-layout>
<div class="flex grow relative">
<ui-tabs v-model="active" :entries="$page.props.setting_menu"></ui-tabs>
<slot></slot>
</div>
</template>
<script>
export default {
data: function () {
return {
innerActive: 0,
innerActive: this.$page.props.setting_menu.findIndex((menu) => menu.is_active),
};
},
computed: {
@ -19,19 +17,15 @@ export default {
get() {
return this.innerActive;
},
set(v, old) {
set(v) {
var _self = this;
this.$inertia.visit(this.$page.props.setting_menu[v].url, {
onSuccess(page) {
console.log('A');
onSuccess() {
_self.innerActive = v;
},
});
},
},
},
mounted() {
this.innerActive = this.$page.props.setting_menu.findIndex((menu) => menu.is_active);
},
};
</script>

View File

@ -1,41 +1,42 @@
<template>
<form id="mailmansettingform" class="grow p-6 grid grid-cols-2 gap-3 items-start content-start" @submit.prevent="submit">
<f-save-button form="mailmansettingform"></f-save-button>
<div class="col-span-full text-gray-100 mb-3">
<p class="text-sm">
Scoutrobot kann automatisch Mailinglisten erstellen, wenn es mit einem existierenden
<a href="https://docs.mailman3.org/en/latest/">Mailman Server</a> verbunden wird. Mailman ist ein OpenSource-Mailinglisten-System, um E-Mails an mehrere Leute zu senden.
</p>
<p class="text-sm mt-1">Scoutrobot wird nach der Ersteinrichtung deine Mitglieder zu bestehenden E-Mail-Verteilern hinzufügen.</p>
</div>
<div>
<f-switch id="is_active" v-model="inner.is_active" label="Mailman-Synchronisation aktiv"></f-switch>
</div>
<div class="flex h-full items-center">
<svg-sprite :src="stateDisplay.icon" :class="stateDisplay.text" class="w-5 h-5"></svg-sprite>
<span class="ml-3" :class="stateDisplay.text" v-text="stateDisplay.label"></span>
</div>
<f-text label="URL" hint="URL der Mailman Api" name="base_url" id="base_url" v-model="inner.base_url"></f-text>
<f-text label="Benutzername" name="username" id="username" v-model="inner.username"></f-text>
<f-text type="password" label="Passwort" name="password" id="password" v-model="inner.password"></f-text>
<f-select label="Liste für alle Mitglieder" name="all_list" id="all_list" v-model="inner.all_list" :options="lists"></f-select>
<f-select label="Liste für Eltern" name="all_parents_list" id="all_parents_list" v-model="inner.all_parents_list" :options="lists"></f-select>
<f-select label="Liste für aktive Leiter" name="active_leaders_list" id="active_leaders_list" v-model="inner.active_leaders_list" :options="lists"></f-select>
<f-select label="Liste für passive Leiter" name="passive_leaders_list" id="passive_leaders_list" v-model="inner.passive_leaders_list" :options="lists"></f-select>
<div></div>
</form>
<page-layout>
<setting-layout>
<form id="mailmansettingform" class="grow p-6 grid grid-cols-2 gap-3 items-start content-start" @submit.prevent="submit">
<f-save-button form="mailmansettingform"></f-save-button>
<div class="col-span-full text-gray-100 mb-3">
<p class="text-sm">
Scoutrobot kann automatisch Mailinglisten erstellen, wenn es mit einem existierenden
<a href="https://docs.mailman3.org/en/latest/">Mailman Server</a> verbunden wird. Mailman ist ein OpenSource-Mailinglisten-System, um E-Mails an mehrere Leute zu senden.
</p>
<p class="text-sm mt-1">Scoutrobot wird nach der Ersteinrichtung deine Mitglieder zu bestehenden E-Mail-Verteilern hinzufügen.</p>
</div>
<div>
<f-switch id="is_active" v-model="inner.is_active" label="Mailman-Synchronisation aktiv"></f-switch>
</div>
<div class="flex h-full items-center">
<svg-sprite :src="stateDisplay.icon" :class="stateDisplay.text" class="w-5 h-5"></svg-sprite>
<span class="ml-3" :class="stateDisplay.text" v-text="stateDisplay.label"></span>
</div>
<f-text label="URL" hint="URL der Mailman Api" name="base_url" id="base_url" v-model="inner.base_url"></f-text>
<f-text label="Benutzername" name="username" id="username" v-model="inner.username"></f-text>
<f-text type="password" label="Passwort" name="password" id="password" v-model="inner.password"></f-text>
<f-select label="Liste für alle Mitglieder" name="all_list" id="all_list" v-model="inner.all_list" :options="lists"></f-select>
<f-select label="Liste für Eltern" name="all_parents_list" id="all_parents_list" v-model="inner.all_parents_list" :options="lists"></f-select>
<f-select label="Liste für aktive Leiter" name="active_leaders_list" id="active_leaders_list" v-model="inner.active_leaders_list" :options="lists"></f-select>
<f-select label="Liste für passive Leiter" name="passive_leaders_list" id="passive_leaders_list" v-model="inner.passive_leaders_list" :options="lists"></f-select>
<div></div>
</form>
</setting-layout>
</page-layout>
</template>
<script>
import AppLayout from '../../layouts/AppLayout.vue';
import SettingLayout from './Layout.vue';
export default {
layout: [AppLayout, SettingLayout],
data: function () {
return {
inner: {},
inner: {...this.data},
};
},
props: {
@ -78,8 +79,8 @@ export default {
});
},
},
created() {
this.inner = this.data;
components: {
SettingLayout,
},
};
</script>

View File

@ -20,6 +20,14 @@ use App\Initialize\Actions\InitializeFormAction;
use App\Initialize\Actions\NamiGetSearchLayerAction;
use App\Initialize\Actions\NamiLoginCheckAction;
use App\Initialize\Actions\NamiSearchAction;
use App\Maildispatcher\Actions\CreateAction;
use App\Maildispatcher\Actions\DestroyAction;
use App\Maildispatcher\Actions\EditAction;
use App\Maildispatcher\Actions\IndexAction;
use App\Maildispatcher\Actions\StoreAction as MaildispatcherStoreAction;
use App\Maildispatcher\Actions\UpdateAction as MaildispatcherUpdateAction;
use App\Mailgateway\Actions\StoreAction;
use App\Mailgateway\Actions\UpdateAction;
use App\Member\Actions\ExportAction;
use App\Member\Actions\MemberResyncAction;
use App\Member\Actions\MemberShowAction;
@ -78,4 +86,14 @@ Route::group(['middleware' => 'auth:web'], function (): void {
Route::get('/contribution', ContributionFormAction::class)->name('contribution.form');
Route::get('/contribution-generate', ContributionGenerateAction::class)->name('contribution.generate');
Route::post('/contribution-validate', ContributionValidateAction::class)->name('contribution.validate');
// ----------------------------------- mail ------------------------------------
Route::post('/api/mailgateway', StoreAction::class)->name('mailgateway.store');
Route::patch('/api/mailgateway/{mailgateway}', UpdateAction::class)->name('mailgateway.update');
Route::get('/maildispatcher', IndexAction::class)->name('maildispatcher.index');
Route::get('/maildispatcher/create', CreateAction::class)->name('maildispatcher.create');
Route::get('/maildispatcher/{maildispatcher}', EditAction::class)->name('maildispatcher.edit');
Route::patch('/maildispatcher/{maildispatcher}', MaildispatcherUpdateAction::class)->name('maildispatcher.update');
Route::post('/maildispatcher', MaildispatcherStoreAction::class)->name('maildispatcher.store');
Route::delete('/maildispatcher/{maildispatcher}', DestroyAction::class)->name('maildispatcher.destroy');
});

View File

@ -1,6 +1,6 @@
<?php
namespace Tests\Feature\Bill;
namespace Tests\Feature\Invoice;
use App\Invoice\InvoiceSettings;
use Illuminate\Foundation\Testing\DatabaseTransactions;

View File

@ -0,0 +1,51 @@
<?php
namespace Tests\Feature\Maildispatcher;
use App\Activity;
use App\Maildispatcher\Models\Maildispatcher;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Types\LocalType;
use App\Member\Member;
use App\Member\Membership;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class StoreTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami();
}
public function testItCanStoreAMail(): void
{
$gateway = Mailgateway::factory()->type(LocalType::class, [])->domain('example.com')->create();
Member::factory()->defaults()->create();
Member::factory()->defaults()->has(Membership::factory()->inLocal('Leiter*in', 'Wölfling'))->create(['email' => 'jane@example.com']);
$activityId = Activity::first()->id;
$response = $this->postJson('/maildispatcher', [
'name' => 'test',
'gateway_id' => $gateway->id,
'filter' => ['activity_ids' => [$activityId]],
]);
$response->assertStatus(201);
$this->assertDatabaseHas('maildispatchers', [
'name' => 'test',
'gateway_id' => $gateway->id,
'filter' => "{\"activity_ids\":[{$activityId}]}",
]);
$dispatcher = Maildispatcher::first();
$this->assertDatabaseCount('localmaildispatchers', 1);
$this->assertDatabaseHas('localmaildispatchers', [
'from' => 'test@example.com',
'to' => 'jane@example.com',
]);
}
}

View File

@ -0,0 +1,92 @@
<?php
namespace Tests\Feature\Mailgateway;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Types\LocalType;
use App\Mailgateway\Types\MailmanType;
use App\Mailman\Support\MailmanService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Phake;
use Tests\RequestFactories\MailmanTypeRequest;
use Tests\TestCase;
class IndexTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami();
}
public function testItCanViewIndexPage(): void
{
$response = $this->get('/setting/mailgateway');
$response->assertOk();
}
public function testItDisplaysLocalGateways(): void
{
$this->withoutExceptionHandling();
$mailgateway = Mailgateway::factory()->type(LocalType::class, [])->name('Lore')->domain('example.com')->create();
$response = $this->get('/setting/mailgateway');
$this->assertInertiaHas('example.com', $response, 'data.data.0.domain');
$this->assertInertiaHas('Lore', $response, 'data.data.0.name');
$this->assertInertiaHas('Lokal', $response, 'data.data.0.type_human');
$this->assertInertiaHas(true, $response, 'data.data.0.works');
$this->assertInertiaHas($mailgateway->id, $response, 'data.data.0.id');
$this->assertInertiaHas(route('mailgateway.update', ['mailgateway' => $mailgateway->id]), $response, 'data.data.0.links.update');
}
public function testItDisplaysMailmanGateways(): void
{
$this->stubIo(MailmanService::class, function ($mock) {
Phake::when($mock)->setCredentials()->thenReturn($mock);
Phake::when($mock)->check()->thenReturn(true);
});
$this->withoutExceptionHandling();
Mailgateway::factory()->type(MailmanType::class, MailmanTypeRequest::new()->create())->create();
$this->get('/setting/mailgateway')->assertOk();
}
public function testItHasMeta(): void
{
$this->withoutExceptionHandling();
$response = $this->get('/setting/mailgateway');
$this->assertInertiaHas(route('mailgateway.store'), $response, 'data.meta.links.store');
$this->assertInertiaHas([
'id' => null,
'name' => '-- kein --',
], $response, 'data.meta.types.0');
$this->assertInertiaHas([
'id' => LocalType::class,
'name' => 'Lokal',
], $response, 'data.meta.types.1');
$this->assertInertiaHas([
'id' => MailmanType::class,
'fields' => [
[
'name' => 'url',
'is_required' => true,
],
],
], $response, 'data.meta.types.2');
$this->assertInertiaHas([
'domain' => '',
'name' => '',
'type' => [
'params' => [],
'cls' => null,
],
], $response, 'data.meta.default');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace Tests\Feature\Mailgateway;
use App\Mailgateway\Types\MailmanType;
use App\Mailman\Support\MailmanService;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Phake;
use Tests\TestCase;
class MailmanTypeTest extends TestCase
{
use DatabaseTransactions;
public function testItChecksForWorks(): void
{
$this->withoutExceptionHandling();
$this->stubIo(MailmanService::class, function ($mock) {
Phake::when($mock)->setCredentials('https://example.com', 'user', 'secret')->thenReturn($mock);
Phake::when($mock)->check()->thenReturn(true);
});
$type = app(MailmanType::class)->setParams([
'url' => 'https://example.com',
'user' => 'user',
'password' => 'secret',
]);
$this->assertTrue($type->works());
}
public function testItCanReturnFalse(): void
{
$this->withoutExceptionHandling();
$this->stubIo(MailmanService::class, function ($mock) {
Phake::when($mock)->setCredentials('https://example.com', 'user', 'secret')->thenReturn($mock);
Phake::when($mock)->check()->thenReturn(false);
});
$type = app(MailmanType::class)->setParams([
'url' => 'https://example.com',
'user' => 'user',
'password' => 'secret',
]);
$this->assertFalse($type->works());
}
}

View File

@ -0,0 +1,91 @@
<?php
namespace Tests\Feature\Mailgateway;
use App\Mailgateway\Types\LocalType;
use App\Mailgateway\Types\MailmanType;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Phake;
use Tests\RequestFactories\MailgatewayRequestFactory;
use Tests\RequestFactories\MailmanTypeRequest;
use Tests\TestCase;
class StoreTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami();
}
public function testItCanStoreALocalGateway(): void
{
$response = $this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->name('lala')->type(LocalType::class, [])->domain('example.com')->create());
$response->assertOk();
$this->assertDatabaseHas('mailgateways', [
'domain' => 'example.com',
'name' => 'lala',
'type' => json_encode([
'cls' => LocalType::class,
'params' => [],
]),
]);
}
public function testItCanStoreAMailmanGateway(): void
{
$typeParams = ['url' => 'https://example.com', 'user' => 'user', 'password' => 'secret'];
$this->stubIo(MailmanType::class, function ($mock) use ($typeParams) {
Phake::when($mock)->setParams($typeParams)->thenReturn($mock);
Phake::when($mock)->works()->thenReturn(true);
});
$this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->type(MailmanType::class, MailmanTypeRequest::new()->create($typeParams))->create());
$this->assertDatabaseHas('mailgateways', [
'type' => json_encode([
'cls' => MailmanType::class,
'params' => $typeParams,
]),
]);
}
public function testItThrowsErrorWhenMailmanConnectionFailed(): void
{
$typeParams = ['url' => 'https://example.com', 'user' => 'user', 'password' => 'secret'];
$this->stubIo(MailmanType::class, function ($mock) use ($typeParams) {
Phake::when($mock)->setParams($typeParams)->thenReturn($mock);
Phake::when($mock)->works()->thenReturn(false);
});
$this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->type(MailmanType::class, MailmanTypeRequest::new()->create($typeParams))->create())
->assertJsonValidationErrors('connection');
}
public function testItValidatesCustomFields(): void
{
$typeParams = ['url' => 'https://example.com', 'user' => '', 'password' => 'secret'];
$this->stubIo(MailmanType::class, function ($mock) use ($typeParams) {
Phake::when($mock)->setParams($typeParams)->thenReturn($mock);
Phake::when($mock)->works()->thenReturn(false);
});
$this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->type(MailmanType::class, MailmanTypeRequest::new()->create($typeParams))->create())
->assertJsonValidationErrors(['type.params.user' => 'Benutzer ist erforderlich.']);
}
public function testItValidatesType(): void
{
$this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->missingType()->create())
->assertJsonValidationErrors('type.cls');
}
public function testItValidatesNameAndDomain(): void
{
$this->postJson('/api/mailgateway', MailgatewayRequestFactory::new()->withoutName()->withoutDomain()->create())
->assertJsonValidationErrors('domain')
->assertJsonValidationErrors('name');
}
}

View File

@ -0,0 +1,31 @@
<?php
namespace Tests\Feature\Mailgateway;
use App\Mailgateway\Models\Mailgateway;
use App\Mailgateway\Types\LocalType;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\RequestFactories\MailgatewayRequestFactory;
use Tests\TestCase;
class UpdateTest extends TestCase
{
use DatabaseTransactions;
public function setUp(): void
{
parent::setUp();
$this->login()->loginNami();
}
public function testItCanUpdateALocalGateway(): void
{
$mailgateway = Mailgateway::factory()->type(LocalType::class, [])->create();
$response = $this->patchJson("/api/mailgateway/{$mailgateway->id}", MailgatewayRequestFactory::new()->name('lala')->type(LocalType::class, [])->domain('example.com')->create());
$response->assertOk();
$this->assertDatabaseCount('mailgateways', 1);
}
}

View File

@ -194,12 +194,12 @@ class IndexTest extends TestCase
Member::factory()->defaults()->for($group1)->create();
Member::factory()->defaults()->for($group1)->create();
$oneResponse = $this->callFilter('member.index', ['group_id' => $group1->id]);
$twoResponse = $this->callFilter('member.index', ['group_id' => $group2->id]);
$oneResponse = $this->callFilter('member.index', ['group_ids' => [$group1->id]]);
$twoResponse = $this->callFilter('member.index', ['group_ids' => [$group2->id]]);
$this->assertCount(3, $this->inertia($oneResponse, 'data.data'));
$this->assertCount(0, $this->inertia($twoResponse, 'data.data'));
$this->assertInertiaHas($group1->id, $oneResponse, 'data.meta.filter.group_id');
$this->assertInertiaHas([$group1->id], $oneResponse, 'data.meta.filter.group_ids');
}
public function testItFiltersForAusstand(): void

View File

@ -0,0 +1,61 @@
<?php
namespace Tests\RequestFactories;
use App\Mailgateway\Types\Type;
use Worksome\RequestFactories\RequestFactory;
class MailgatewayRequestFactory extends RequestFactory
{
public function definition(): array
{
return [
'name' => $this->faker->words(5, true),
'type' => [
'cls' => app('mail-gateways')->random(),
'params' => [],
],
'domain' => $this->faker->safeEmailDomain(),
];
}
public function name(string $name): self
{
return $this->state(['name' => $name]);
}
public function domain(string $domain): self
{
return $this->state(['domain' => $domain]);
}
/**
* @param class-string<Type> $type
* @param array<string, mixed> $params
*/
public function type(string $type, array $params): self
{
return $this->state(['type' => [
'cls' => $type,
'params' => $params,
]]);
}
public function missingType(): self
{
return $this->state(['type' => [
'cls' => null,
'params' => [],
]]);
}
public function withoutName(): self
{
return $this->state(['name' => '']);
}
public function withoutDomain(): self
{
return $this->state(['domain' => '']);
}
}

View File

@ -0,0 +1,39 @@
<?php
namespace Tests\RequestFactories;
use App\Mailman\Data\MailingList;
use Worksome\RequestFactories\RequestFactory;
class MailinglistFactory extends RequestFactory
{
public function definition(): array
{
return [
'description' => $this->faker->words(5, true),
'displayName' => $this->faker->words(5, true),
'fqdnListname' => $this->faker->safeEmail(),
'listId' => $this->faker->domainName(),
'listName' => $this->faker->words(5, true),
'mailHost' => $this->faker->domainName(),
'memberCount' => $this->faker->numberBetween(10, 100),
'selfLink' => $this->faker->url(),
'volume' => 1,
];
}
/**
* @param array<string, mixed> $attributes
*/
public function toData(array $attributes = []): MailingList
{
return MailingList::from($this->create($attributes));
}
public function id(string $id): self
{
return $this->state([
'list_id' => $id,
]);
}
}

View File

@ -0,0 +1,17 @@
<?php
namespace Tests\RequestFactories;
use Worksome\RequestFactories\RequestFactory;
class MailmanTypeRequest extends RequestFactory
{
public function definition(): array
{
return [
'url' => 'https://'.$this->faker->domainName(),
'user' => $this->faker->firstName(),
'password' => $this->faker->password(),
];
}
}

View File

@ -45,16 +45,16 @@ class ServiceTest extends TestCase
Http::fake([
'http://mailman.test/api/lists/listid/roster/member?page=1&count=10' => Http::response(json_encode([
'entries' => [
['email' => 'test@example.com'],
['email' => 'test2@example.com'],
['email' => 'test@example.com', 'self_link' => 'https://example.com/994'],
],
'total_size' => 2,
]), 200),
]);
$result = app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members('listid');
$result = app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members(MailingList::factory()->id('listid')->toData())->first();
$this->assertEquals(['test@example.com', 'test2@example.com'], $result->toArray());
$this->assertEquals(994, $result->memberId);
$this->assertEquals('test@example.com', $result->email);
Http::assertSentCount(1);
Http::assertSent(fn ($request) => 'GET' === $request->method() && 'http://mailman.test/api/lists/listid/roster/member?page=1&count=10' === $request->url() && $request->header('Authorization') === ['Basic '.base64_encode('user:secret')]);
}
@ -66,7 +66,7 @@ class ServiceTest extends TestCase
'http://mailman.test/api/lists/listid/roster/member?page=1&count=10' => Http::response('', 401),
]);
app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members('listid')->first();
app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members(MailingList::factory()->id('listid')->toData())->first();
}
public function testItCanGetLists(): void
@ -94,7 +94,7 @@ class ServiceTest extends TestCase
foreach (range(3, 40) as $i) {
yield [
collect(range(1, $i))
->map(fn ($num) => ['email' => 'test'.$num.'@example.com'])
->map(fn ($num) => ['email' => 'test'.$num.'@example.com', 'self_link' => 'https://example.com/994'])
->toArray(),
];
}
@ -115,7 +115,7 @@ class ServiceTest extends TestCase
]);
}
$result = app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members('listid');
$result = app(MailmanService::class)->setCredentials('http://mailman.test/api/', 'user', 'secret')->members(MailingList::factory()->id('listid')->toData());
$this->assertCount($totals->count(), $result->toArray());
Http::assertSentCount($totals->chunk(10)->count());