diff --git a/app/Console/Commands/NotifyInactiveProjects.php b/app/Console/Commands/NotifyInactiveProjects.php new file mode 100644 index 00000000..9b414c20 --- /dev/null +++ b/app/Console/Commands/NotifyInactiveProjects.php @@ -0,0 +1,133 @@ +option('months') ?? config('inactivity.grace_months', 6)); + $threshold = Carbon::now()->subMonths($months); + + return DB::transaction(function () use ($threshold) { + // Identify inactive projects and mark them inactive + $projects = Project::with('owner', 'users') + ->where('is_public', false) + ->where('is_deleted', false) + ->where('is_archived', false) + ->where('updated_at', '<', $threshold) + ->get(); + + if ($this->option('list')) { + $this->table(['ID', 'Name', 'Owner Email', 'Updated At'], $projects->map(function ($p) { + return [$p->id, $p->name, optional($p->owner)->email, (string) $p->updated_at]; + })->toArray()); + $this->info('Total inactive projects: '.count($projects)); + + return self::SUCCESS; + } + + // Mark these projects as inactive in DB (idempotent) without touching updated_at + if ($projects->count() > 0) { + $ids = $projects->pluck('id'); + Project::withoutTimestamps(function () use ($ids) { + Project::whereIn('id', $ids)->update(['active' => false]); + }); + } + + // Aggregate by recipient so each user gets a single digest listing all of their inactive projects + $recipientProjects = []; + foreach ($projects as $project) { + foreach ($this->prepareSendList($project) as $recipient) { + $recipientProjects[$recipient->id]['user'] = $recipient; + $recipientProjects[$recipient->id]['projects'][] = $project; + } + } + + // Send one email per recipient with their list of inactive projects + $sentCount = 0; + foreach ($recipientProjects as $entry) { + /** @var \App\Models\User $user */ + $user = $entry['user']; + $list = collect($entry['projects'])->map(function ($p) { + return [ + 'id' => $p->id, + 'name' => $p->name, + 'updated_at' => (string) $p->updated_at, + 'url' => url(config('app.url').'/dashboard/projects/'.$p->id), + ]; + })->values()->all(); + + Notification::send($user, new ProjectInactivityReminderNotification($list)); + $sentCount++; + } + + $this->info('Inactive project digests sent: '.$sentCount.' (covering '.count($projects).' projects)'); + + if ($this->option('report-admins') && $projects->count() > 0) { + $payload = $projects->map(function ($p) { + return [ + 'id' => $p->id, + 'name' => $p->name, + 'owner' => optional($p->owner)->email ?? 'N/A', + 'updated_at' => (string) $p->updated_at, + ]; + })->values()->all(); + + Notification::send(User::role(['super-admin'])->get(), new ProjectInactivityReportToAdmins($payload)); + $this->info('Admin report sent to super-admins.'); + } + + return self::SUCCESS; + }); + } + + /** + * Prepare recipients list (owner and creators). + */ + protected function prepareSendList(Project $project): array + { + $sendTo = []; + $add = function ($user) use (&$sendTo) { + if ($user && isset($user->id)) { + $sendTo[$user->id] = $user; + } + }; + + foreach ($project->allUsers() as $member) { + if ($member->projectMembership->role == 'creator' || $member->projectMembership->role == 'owner') { + $add($member); + } + } + + $add($project->owner); + + return array_values($sendTo); + } +} diff --git a/app/Mail/ProjectInactivityReminder.php b/app/Mail/ProjectInactivityReminder.php new file mode 100644 index 00000000..81bc84af --- /dev/null +++ b/app/Mail/ProjectInactivityReminder.php @@ -0,0 +1,47 @@ +projectOrList = $projectOrList; + } + + /** + * Build the message. + */ + public function build() + { + // Digest mode when an array is passed + if (is_array($this->projectOrList)) { + return $this->markdown('vendor.mail.project-inactivity-reminder', [ + 'digest' => true, + 'projects' => $this->projectOrList, + 'thresholdMonths' => (int) config('inactivity.grace_months', 6), + ])->subject(__('Your inactive projects digest')); + } + + // Single project mode (backwards compatible) + $project = $this->projectOrList; + + return $this->markdown('vendor.mail.project-inactivity-reminder', [ + 'url' => url(config('app.url').'/dashboard/projects/'.$project->id), + 'projectName' => $project->name, + 'lastUpdated' => explode(' ', \Illuminate\Support\Carbon::parse($project->updated_at))[0], + 'digest' => false, + ])->subject(__('Your project has been inactive'.' - '.$project->name)); + } +} diff --git a/app/Mail/ProjectInactivityReportToAdmins.php b/app/Mail/ProjectInactivityReportToAdmins.php new file mode 100644 index 00000000..9bc2502a --- /dev/null +++ b/app/Mail/ProjectInactivityReportToAdmins.php @@ -0,0 +1,26 @@ + $projects */ + public function __construct(private array $projects) + { + // + } + + public function build(): self + { + return $this->markdown('vendor.mail.project-inactivity-report-admins', [ + 'projects' => $this->projects, + 'thresholdMonths' => (int) config('inactivity.grace_months', 6), + ])->subject(__('Inactive projects report')); + } +} diff --git a/app/Models/Project.php b/app/Models/Project.php index 8cad91ce..dba8ff0a 100644 --- a/app/Models/Project.php +++ b/app/Models/Project.php @@ -7,6 +7,7 @@ use App\Events\ProjectDeletion; use App\Notifications\ProjectDeletionFailureNotification; use App\Notifications\ProjectDeletionReminderNotification; +use App\Notifications\ProjectInactivityReminderNotification; use App\Traits\CacheClear; use Auth; use Carbon\Carbon; @@ -42,6 +43,7 @@ class Project extends Model implements Auditable 'starred', 'location', 'is_public', + 'active', 'obfuscationcode', 'description', 'type', @@ -59,6 +61,16 @@ class Project extends Model implements Auditable 'species', ]; + /** + * Casts for the model attributes. + */ + protected function casts(): array + { + return [ + 'active' => 'boolean', + ]; + } + protected static $marks = [ Like::class, Bookmark::class, @@ -285,6 +297,19 @@ public function validation(): BelongsTo return $this->belongsTo(Validation::class, 'validation_id'); } + /** + * Model boot method to ensure that any meaningful update re-activates the project. + */ + protected static function booted(): void + { + static::saving(function (Project $project): void { + // If something changes other than 'active' itself, mark as active again + if ($project->isDirty() && ! $project->isDirty('active')) { + $project->active = true; + } + }); + } + /** * Determine if the model should be searchable. * @@ -354,6 +379,9 @@ public function sendNotification($notifyType, $sendTo) case 'publish': event(new DraftProcessed($this, $sendTo)); break; + case 'inactivityReminder': + Notification::send($sendTo, new ProjectInactivityReminderNotification($this)); + break; } } } diff --git a/app/Notifications/ProjectInactivityReminderNotification.php b/app/Notifications/ProjectInactivityReminderNotification.php new file mode 100644 index 00000000..59d2d602 --- /dev/null +++ b/app/Notifications/ProjectInactivityReminderNotification.php @@ -0,0 +1,46 @@ +payload))->to($notifiable->email); + } + + /** + * Get the array representation of the notification. + */ + public function toArray($notifiable): array + { + return []; + } +} diff --git a/app/Notifications/ProjectInactivityReportToAdmins.php b/app/Notifications/ProjectInactivityReportToAdmins.php new file mode 100644 index 00000000..c81e23f8 --- /dev/null +++ b/app/Notifications/ProjectInactivityReportToAdmins.php @@ -0,0 +1,35 @@ + $projects */ + public function __construct(private array $projects) + { + // + } + + public function via($notifiable): array + { + return ['mail']; + } + + public function toMail($notifiable): Mailable + { + return (new ProjectInactivityReportToAdminsMailable($this->projects))->to($notifiable->email); + } + + public function toArray($notifiable): array + { + return []; + } +} diff --git a/config/inactivity.php b/config/inactivity.php new file mode 100644 index 00000000..bc203426 --- /dev/null +++ b/config/inactivity.php @@ -0,0 +1,6 @@ + (int) env('INACTIVITY_GRACE_MONTHS', 6), +]; diff --git a/database/factories/ProjectFactory.php b/database/factories/ProjectFactory.php index 6d0ce5ee..74da8bb8 100644 --- a/database/factories/ProjectFactory.php +++ b/database/factories/ProjectFactory.php @@ -24,6 +24,7 @@ public function definition(): array 'is_public' => false, 'is_deleted' => false, 'is_archived' => false, + 'active' => true, 'status' => 'draft', 'process_logs' => null, 'location' => null, // todo: Adjust when location field is provided in nmrXiv diff --git a/database/migrations/2025_09_25_000001_add_active_column_to_projects_table.php b/database/migrations/2025_09_25_000001_add_active_column_to_projects_table.php new file mode 100644 index 00000000..45c677ec --- /dev/null +++ b/database/migrations/2025_09_25_000001_add_active_column_to_projects_table.php @@ -0,0 +1,30 @@ +boolean('active')->default(true); + $table->index('active'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('projects', function (Blueprint $table) { + $table->dropIndex(['active']); + $table->dropColumn('active'); + }); + } +}; diff --git a/resources/js/Pages/Project/Index.vue b/resources/js/Pages/Project/Index.vue index 3b9765b1..d4dfb62c 100644 --- a/resources/js/Pages/Project/Index.vue +++ b/resources/js/Pages/Project/Index.vue @@ -174,6 +174,12 @@ > {{ project.name }} + + Inactive + {{ project.name }} + + Inactive +