feat: email sub/unsub/queue end-to-end rough draft

This commit is contained in:
Jonathan Nielson 2025-10-11 23:36:10 -05:00
parent 53740b8ad3
commit 0ad6f8d833
37 changed files with 845 additions and 46 deletions

View File

@ -1,3 +1,7 @@
PUBLISHING_NAME="Word Salad"
SUBHEAD="A blog with an as yet unclear direction."
CONTACT_EMAIL="test@example.org"
APP_NAME=Laravel
APP_ENV=local
APP_KEY=
@ -13,7 +17,7 @@ DB_CONNECTION=sqlite
BROADCAST_DRIVER=log
CACHE_DRIVER=file
FILESYSTEM_DISK=local
QUEUE_CONNECTION=sync
QUEUE_CONNECTION=database
SESSION_DRIVER=file
SESSION_LIFETIME=120
@ -24,8 +28,4 @@ MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS="hello@example.com"
MAIL_FROM_NAME="${APP_NAME}"
PUBLISHING_NAME="Word Salad"
SUBHEAD="A blog with an as yet unclear direction."
CONTACT_EMAIL="test@example.org"
MAIL_FROM_NAME="${PUBLISHING_NAME}"

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Mail\NewArticlePublished;
use App\Models\Article;
use App\Models\Subscriber;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Mail;
class QueueNewArticleAlertForSubscribers extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'app:queue-new-article-alert-for-subscribers';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Gets all the published articles that have yet to have subscribers notified and queues notification emails for all the subscribers in the list.';
/**
* Execute the console command.
*/
public function handle()
{
$startTime = microtime(true);
// TODO: Incredibly basic implementation here. Won't scale well past a few thousand subscribers
$articlesForNotification = Article::query()
->where('published_at', '<', now())
->whereNull('subscribers_notified_at')
->get();
logger(count($articlesForNotification) . ' article(s) ready for notifying.');
$subscribersToNotify = Subscriber::all();
logger(count($subscribersToNotify) . ' subscribers(s) to notify.');
logger((count($articlesForNotification) * count($subscribersToNotify)) . ' emails being queued for send.');
foreach($articlesForNotification as $article) {
$article->update(['subscribers_notified_at' => now()]);
foreach($subscribersToNotify as $subscriber) {
Mail::to($subscriber)->queue(new NewArticlePublished($article, $subscriber));
}
}
$endTime = microtime(true);
logger('Command Succeeded in ' . ($endTime - $startTime) . ' second(s).');
}
}

View File

@ -12,7 +12,7 @@ class Kernel extends ConsoleKernel
*/
protected function schedule(Schedule $schedule): void
{
// $schedule->command('inspire')->hourly();
$schedule->command('app:queue-new-article-alert-for-subscribers')->everyFiveMinutes()->withoutOverlapping();
}
/**

View File

@ -22,4 +22,19 @@ class ReaderController extends Controller
return redirect(route('subscribe-fail'));
}
}
public function unsubscribe(Request $request): RedirectResponse
{
// Validate email
$validated = $request->validate([
'email' => 'required|email|max:512',
]);
if($validated['email']) {
Subscriber::where('email', $validated['email'])->delete();
return redirect(route('unsubscribe-success'));
} else {
return redirect(route('unsubscribe-fail'));
}
}
}

View File

@ -42,7 +42,9 @@ class WriterController extends Controller
public function dashboard(Request $request): View
{
return view('writer.dashboard');
$allArticles = Article::with(['author'])->orderBy('last_updated')->get();
return view('writer.dashboard', ['articles' => $allArticles]);
}
public function newArticle(Request $request): View

View File

@ -0,0 +1,57 @@
<?php
namespace App\Mail;
use App\Models\Article;
use App\Models\Subscriber;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class NewArticlePublished extends Mailable
{
use Queueable, SerializesModels;
/**
* Create a new message instance.
*/
public function __construct(private Article $article, private ?Subscriber $subscriber)
{
}
/**
* Get the message envelope.
*/
public function envelope(): Envelope
{
return new Envelope(
subject: $this->article->title,
);
}
/**
* Get the message content definition.
*/
public function content(): Content
{
return new Content(
markdown: 'mail.articles.published',
with: [
'article' => $this->article,
'email' => $this?->subscriber?->email,
],
);
}
/**
* Get the attachments for the message.
*
* @return array<int, \Illuminate\Mail\Mailables\Attachment>
*/
public function attachments(): array
{
return [];
}
}

View File

@ -27,6 +27,7 @@ class Article extends Model
'header_image_url',
'markdown_body',
'processed_at',
'subscribers_notified_at',
];
/** ---------------------------------------

View File

@ -33,6 +33,10 @@
}
},
"scripts": {
"mailpit": [
"Composer\\Config::disableProcessTimeout",
"mailpit"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"

View File

@ -44,6 +44,10 @@ return new class extends Migration
// HTML body was generated (among other things)
$table->dateTime('processed_at')->nullable();
// The dateTime when the emails were queued for sending for all subscribers
// after first publishing
$table->dateTime('subscribers_notified_at')->nullable();
$table->timestamps();
});
}

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.
*/
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->bigIncrements('id');
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('jobs');
}
};

View File

@ -1,3 +1,11 @@
.is-invalid {
border: red 4px solid;
}
main {
padding: 0 1em;
}
#article-list-table {
text-overflow: ellipsis;
}

View File

@ -179,6 +179,10 @@ footer {
margin-bottom: 2.5em;
}
#writer-login {
min-height: 60vh;
}
/* .bubble-panel {
border-radius: 50px;
background: #813d9c;
@ -186,34 +190,3 @@ footer {
33px -33px 65px #9c4abd;
padding: 2em;
}*/
/* Create a custom animation */
@keyframes move-out {
from {
transform: translateY(0%);
}
to {
transform: translateY(-100%);
}
}
@keyframes move-in {
from {
transform: translateY(100%);
}
to {
transform: translateY(0%);
}
}
/* Apply the custom animation to the old and new page states */
::view-transition-old(root) {
animation: 0.4s ease-in both move-out;
}
::view-transition-new(root) {
animation: 0.4s ease-in both move-in;
}

View File

@ -0,0 +1,11 @@
@extends('layouts.guest')
@section('title', 'Un-Subscription Failed')
@section('content')
<section style="max-width: 1100px;">
<h1>Error Attempting to Unsubscribe</h1>
<aside>(yikes, I am embarrased)</aside>
<p>Something went wrong while removing your interest from my list. Please try again, or contact me at {{ config('blog.contact_email') }} if the problem persists.</p>
</section>
@endsection

View File

@ -0,0 +1,26 @@
@extends('layouts.guest')
@section('title', 'Subscription Removed')
@section('content')
<section style="max-width: 1100px;">
<h1>Subscription Removed</h1>
<p>Please allow up to five minutes for this to take effect*. You will no longer receive emails from me. Thank you for your interest.</p>
<hr />
<aside>
<i>
*Ever wonder why unsubscribe pages often say something like this?
Well, there are a number of common reasons, but in this case, it's
because emails take a little bit of time to send. So if I need to
notify 10,000 subscribers about a new article, and it takes a tenth
of a second to send each email, then from the time I start sending
to the first subscriber in the list to the last subscriber, several
minutes have passed. And it's very possible you clicked the
unsubscribe link somewhere in that time frame and an email has
already been queued up to be sent.
</i>
</aside>
</section>
@endsection

View File

@ -0,0 +1,15 @@
@extends('layouts.guest')
@section('title', 'Unsubscribe')
@section('content')
<section>
<h1>Unsubscribe from All Emails</h1>
<form action="{{ route('unsubscribe-save') }}" method="POST">
@csrf
<label for="email">Email Address</label>
<input type="email" id="email" name="email" value="{{ $email }}" required />
<button type="submit">Unsubscribe</button>
</form>
</section>
@endsection

View File

@ -26,11 +26,11 @@
</main>
</body>
<footer>
@if(! in_array(Route::currentRouteName(), ['subscribe-success', 'subscribe-fail']))
@if($showSubscribeForm ?? false)
@include('shared.subscribe-form')
@endif
<div id="footer-copyright">
&#169; {{ (new DateTime())->format('Y') }}
&#169; {{ date('Y') }}
</div>
</footer>
</html>

View File

@ -0,0 +1,26 @@
<x-mail::message>
# {{ $article->title }}
## {{ $article->subtitle }}
Author: {{ $article?->author?->display_name }}
<x-mail::button :url="route('article', ['article_slug' => $article->slug])">
Read in Browser
</x-mail::button>
<hr />
{!! $article->body !!}
<hr />
{{ config('blog.publishing_name') }}
<x-mail::button :url="route('unsubscribe', ['email' => $email])" color="error">
Unsubscribe
</x-mail::button>
</x-mail::message>

View File

@ -0,0 +1,24 @@
@props([
'url',
'color' => 'primary',
'align' => 'center',
])
<table class="action" align="{{ $align }}" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table width="100%" border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="{{ $align }}">
<table border="0" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
<a href="{{ $url }}" class="button button-{{ $color }}" target="_blank" rel="noopener">{{ $slot }}</a>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,11 @@
<tr>
<td>
<table class="footer" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="content-cell" align="center">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>

View File

@ -0,0 +1,12 @@
@props(['url'])
<tr>
<td class="header">
<a href="{{ $url }}" style="display: inline-block;">
@if (trim($slot) === 'Laravel')
<img src="https://laravel.com/img/notification-logo.png" class="logo" alt="Laravel Logo">
@else
{{ $slot }}
@endif
</a>
</td>
</tr>

View File

@ -0,0 +1,57 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<title>{{ config('blog.publishing_name') }}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<meta name="color-scheme" content="light">
<meta name="supported-color-schemes" content="light">
<style>
@media only screen and (max-width: 600px) {
.inner-body {
width: 100% !important;
}
.footer {
width: 100% !important;
}
}
@media only screen and (max-width: 500px) {
.button {
width: 100% !important;
}
}
</style>
</head>
<body>
<table class="wrapper" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td align="center">
<table class="content" width="100%" cellpadding="0" cellspacing="0" role="presentation">
{{ $header ?? '' }}
<!-- Email Body -->
<tr>
<td class="body" width="100%" cellpadding="0" cellspacing="0" style="border: hidden !important;">
<table class="inner-body" align="center" width="570" cellpadding="0" cellspacing="0" role="presentation">
<!-- Body content -->
<tr>
<td class="content-cell">
{{ Illuminate\Mail\Markdown::parse($slot) }}
{{ $subcopy ?? '' }}
</td>
</tr>
</table>
</td>
</tr>
{{ $footer ?? '' }}
</table>
</td>
</tr>
</table>
</body>
</html>

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('blog.publishing_name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('blog.publishing_name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1,14 @@
<table class="panel" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-content">
<table width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td class="panel-item">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>
</td>
</tr>
</table>

View File

@ -0,0 +1,7 @@
<table class="subcopy" width="100%" cellpadding="0" cellspacing="0" role="presentation">
<tr>
<td>
{{ Illuminate\Mail\Markdown::parse($slot) }}
</td>
</tr>
</table>

View File

@ -0,0 +1,3 @@
<div class="table">
{{ Illuminate\Mail\Markdown::parse($slot) }}
</div>

View File

@ -0,0 +1,297 @@
/* Base */
body,
body *:not(html):not(style):not(br):not(tr):not(code) {
box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif,
'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol';
position: relative;
}
body {
-webkit-text-size-adjust: none;
background-color: #ffffff;
color: #718096;
height: 100%;
line-height: 1.4;
margin: 0;
padding: 0;
width: 100% !important;
}
p,
ul,
ol,
blockquote {
line-height: 1.4;
text-align: left;
}
a {
color: #3869d4;
}
a img {
border: none;
}
/* Typography */
h1 {
color: #3d4852;
font-size: 18px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h2 {
font-size: 16px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
h3 {
font-size: 14px;
font-weight: bold;
margin-top: 0;
text-align: left;
}
p {
font-size: 16px;
line-height: 1.5em;
margin-top: 0;
text-align: left;
}
p.sub {
font-size: 12px;
}
img {
max-width: 100%;
}
/* Layout */
.wrapper {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #edf2f7;
margin: 0;
padding: 0;
width: 100%;
}
.content {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 0;
padding: 0;
width: 100%;
}
/* Header */
.header {
padding: 25px 0;
text-align: center;
}
.header a {
color: #550B73;
font-size: 19px;
font-weight: bold;
text-decoration: none;
}
/* Logo */
.logo {
height: 75px;
max-height: 75px;
width: 75px;
}
/* Body */
.body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
background-color: #edf2f7;
border-bottom: 1px solid #edf2f7;
border-top: 1px solid #edf2f7;
margin: 0;
padding: 0;
width: 100%;
}
.inner-body {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
background-color: #ffffff;
border-color: #e8e5ef;
border-radius: 2px;
border-width: 1px;
box-shadow: 0 2px 0 rgba(0, 0, 150, 0.025), 2px 4px 0 rgba(0, 0, 150, 0.015);
margin: 0 auto;
padding: 0;
width: 570px;
}
/* Subcopy */
.subcopy {
border-top: 1px solid #e8e5ef;
margin-top: 25px;
padding-top: 25px;
}
.subcopy p {
font-size: 14px;
}
/* Footer */
.footer {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 570px;
margin: 0 auto;
padding: 0;
text-align: center;
width: 570px;
}
.footer p {
color: #b0adc5;
font-size: 12px;
text-align: center;
}
.footer a {
color: #b0adc5;
text-decoration: underline;
}
/* Tables */
.table table {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
width: 100%;
}
.table th {
border-bottom: 1px solid #edeff2;
margin: 0;
padding-bottom: 8px;
}
.table td {
color: #74787e;
font-size: 15px;
line-height: 18px;
margin: 0;
padding: 10px 0;
}
.content-cell {
max-width: 100vw;
padding: 32px;
}
/* Buttons */
.action {
-premailer-cellpadding: 0;
-premailer-cellspacing: 0;
-premailer-width: 100%;
margin: 30px auto;
padding: 0;
text-align: center;
width: 100%;
}
.button {
-webkit-text-size-adjust: none;
border-radius: 4px;
color: #fff;
display: inline-block;
overflow: hidden;
text-decoration: none;
}
.button-primary {
background-color: #550B73;
border-bottom: 8px solid #550B73;
border-left: 18px solid #550B73;
border-right: 18px solid #550B73;
border-top: 8px solid #550B73;
}
.button-blue {
background-color: #2d3748;
border-bottom: 8px solid #2d3748;
border-left: 18px solid #2d3748;
border-right: 18px solid #2d3748;
border-top: 8px solid #2d3748;
}
.button-green,
.button-success {
background-color: #48bb78;
border-bottom: 8px solid #48bb78;
border-left: 18px solid #48bb78;
border-right: 18px solid #48bb78;
border-top: 8px solid #48bb78;
}
.button-red,
.button-error {
background-color: #e53e3e;
border-bottom: 8px solid #e53e3e;
border-left: 18px solid #e53e3e;
border-right: 18px solid #e53e3e;
border-top: 8px solid #e53e3e;
}
/* Panels */
.panel {
border-left: #2d3748 solid 4px;
margin: 21px 0;
}
.panel-content {
background-color: #edf2f7;
color: #718096;
padding: 16px;
}
.panel-content p {
color: #718096;
}
.panel-item {
padding: 0;
}
.panel-item p:last-of-type {
margin-bottom: 0;
padding-bottom: 0;
}
/* Utilities */
.break-all {
word-break: break-all;
}

View File

@ -0,0 +1 @@
{{ $slot }}: {{ $url }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
[{{ $slot }}]({{ $url }})

View File

@ -0,0 +1,9 @@
{!! strip_tags($header ?? '') !!}
{!! strip_tags($slot) !!}
@isset($subcopy)
{!! strip_tags($subcopy) !!}
@endisset
{!! strip_tags($footer ?? '') !!}

View File

@ -0,0 +1,27 @@
<x-mail::layout>
{{-- Header --}}
<x-slot:header>
<x-mail::header :url="config('app.url')">
{{ config('blog.publishing_name') }}
</x-mail::header>
</x-slot:header>
{{-- Body --}}
{{ $slot }}
{{-- Subcopy --}}
@isset($subcopy)
<x-slot:subcopy>
<x-mail::subcopy>
{{ $subcopy }}
</x-mail::subcopy>
</x-slot:subcopy>
@endisset
{{-- Footer --}}
<x-slot:footer>
<x-mail::footer>
© {{ date('Y') }} {{ config('blog.publishing_name') }}. @lang('All rights reserved.')
</x-mail::footer>
</x-slot:footer>
</x-mail::layout>

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -0,0 +1 @@
{{ $slot }}

View File

@ -5,6 +5,54 @@
@section('content')
<div>
<h1>Writer's Dashboard</h1>
<a href="{{ route('new_article') }}"><button>New Article</button></a>
<hr />
<h3>All Articles</h3>
<table id="article-list-table">
<thead>
<tr>
<th>ID</th>
<th>Slug</th>
<th>Title</th>
<th>Subhead</th>
<th>Author</th>
<th>Published At</th>
<th>Last Updated</th>
<th>First Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@forelse($articles as $article)
<tr>
<td>{{ $article->id }}</td>
<td>{{ $article?->slug ?? '-' }}</td>
<td>{{ $article?->title ?? '-' }}</td>
<td>{{ $article?->subtitle ?? '-' }}</td>
<td>{{ $article?->author?->display_name ?? '-' }}</td>
<td>{{ $article?->published_at ?? '-' }}</td>
<td>{{ $article?->updated_at ?? '-' }}</td>
<td>{{ $article?->created_at ?? '-' }}</td>
<td>
{{--
<a href="{{ route() }}">
<button type="button">Edit</button>
</a>
--}}
<a href="{{}}">
<button type="button">
Unpublish
</button>
</a>
</td>
</tr>
@empty
<tr><td>No Articles</td></tr>
@endforelse
</tbody>
</table>
</div>
@endsection

View File

@ -3,7 +3,7 @@
@section('title', 'Writer Login')
@section('content')
<section class="bubble-panel">
<section id="writer-login">
<h2>Writer Login</h2>
<form action="{{ route('authenticate') }}" method="POST">

View File

@ -19,7 +19,7 @@ use Illuminate\Support\Facades\Route;
Route::get('/', function () {
$articles = Article::with(['author'])->where('published_at', '<', now())->latest()->limit(5)->get();
return view('guest.home', ['articles' => $articles]);
return view('guest.home', ['articles' => $articles, 'showSubscribeForm' => true]);
});
Route::get('/test-insert', function () {
@ -28,7 +28,7 @@ Route::get('/test-insert', function () {
Route::get('/all-articles', function () {
$all_articles = Article::where('published_at', '<', now())->paginate(25);
return view('guest.all-articles', ['all_articles' => $all_articles]);
return view('guest.all-articles', ['all_articles' => $all_articles, 'showSubscribeForm' => true]);
})->name('all-articles');
Route::get('/post/{article_slug}', function ($article_slug) {
@ -40,7 +40,7 @@ Route::get('/post/{article_slug}', function ($article_slug) {
if(empty($active_article)) {
return abort(404);
}
return view('guest.article', ['article' => $active_article]);
return view('guest.article', ['article' => $active_article, 'showSubscribeForm' => true]);
})->name('article');
Route::get('/writer/login', function () {
@ -57,6 +57,27 @@ Route::get('/subscribe-fail', function() {
return view('guest.subscribe-fail');
})->name('subscribe-fail');
Route::get('/unsubscribe', function(Request $request) {
return view('guest.unsubscribe', ['email' => $request->query('email')]);
})->name('unsubscribe');
Route::post('/unsubscribe', [ReaderController::class, 'unsubscribe'])->name('unsubscribe-save');
Route::get('/unsubscribe-success', function() {
return view('guest.unsubscribe-success');
})->name('unsubscribe-success');
Route::get('/unsubscribe-fail', function() {
return view('guest.unsubscribe-fail');
})->name('unsubscribe-fail');
/**
* ----------------
* WRITER ENDPOINTS
* ----------------
*/
Route::post('/writer/authenticate', [WriterController::class, 'authenticate'])->name('authenticate');
Route::middleware('auth')->group(function () {