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
+