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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/Actions/Teams/DownloadTeamDataAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ public function run(User $user, Team $team, array $dateFilter = [])

/* Dispatch job to create CSV file for export */
(new CreateCSVExport(null, null, $team->id, null, $dateFilter))
->notifyOnFailure($user->email)
->queue($path, 's3', null, ['visibility' => 'public'])
->chain([
// These jobs are executed when above is finished.
Expand Down
44 changes: 43 additions & 1 deletion app/Exports/CreateCSVExport.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
use App\Mail\ExportFailed;

use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\Exportable;
Expand All @@ -25,6 +28,8 @@ class CreateCSVExport implements FromQuery, WithMapping, WithHeadings
use Exportable, Dispatchable, InteractsWithQueue, Queueable, SerializesModels;

public $location_type, $location_id, $team_id, $user_id;
/** @var string|null recipient for failure notification */
public ?string $notifyEmail = null;
/** @var array */
private $dateFilter;

Expand Down Expand Up @@ -180,7 +185,7 @@ public function map($row): array
{
$result = [
$row->id,
$row->verified->value,
$row->verified?->value ?? 0,
$row->model,
$row->datetime,
$row->created_at,
Expand Down Expand Up @@ -283,6 +288,43 @@ public function query()
);
}

/**
* Fluent setter for the recipient email — used to notify the user if the export fails.
*/
public function notifyOnFailure(?string $email): self
{
$this->notifyEmail = $email;

return $this;
}

/**
* Called by Maatwebsite Excel when any queued sheet job fails.
* We email the user so they aren't left waiting forever for a download that will never arrive.
*/
public function failed(\Throwable $e): void
{
Log::error('CreateCSVExport failed', [
'user_id' => $this->user_id,
'team_id' => $this->team_id,
'location_type' => $this->location_type,
'location_id' => $this->location_id,
'notifyEmail' => $this->notifyEmail,
'error' => $e->getMessage(),
]);

if ($this->notifyEmail) {
try {
Mail::to($this->notifyEmail)->send(new ExportFailed());
} catch (\Throwable $mailError) {
Log::error('Failed to send ExportFailed mail', [
'to' => $this->notifyEmail,
'error' => $mailError->getMessage(),
]);
}
}
}

/**
* Apply the export scope (user/team/location + date filter + verification) to a query.
*/
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/DownloadControllerNew.php
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ public function index (Request $request)

/* Dispatch job to create CSV file for export */
(new CreateCSVExport($request->locationType, $location_id))
->notifyOnFailure($email)
->queue($path, 's3', null, ['visibility' => 'public'])
->chain([
// These jobs are executed when above is finished.
Expand Down
1 change: 1 addition & 0 deletions app/Http/Controllers/User/ProfileController.php
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ public function download (Request $request)

/* Dispatch job to create CSV file for export */
(new CreateCSVExport(null, null, null, $user->id, $dateFilter))
->notifyOnFailure($user->email)
->queue($path, 's3', null, ['visibility' => 'public'])
->chain([
// These jobs are executed when above is finished.
Expand Down
19 changes: 19 additions & 0 deletions app/Mail/ExportFailed.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php

namespace App\Mail;

use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;

class ExportFailed extends Mailable
{
use Queueable, SerializesModels;

public function build()
{
return $this->from('info@openlittermap.com')
->subject('OpenLitterMap Data Export — Failed')
->view('emails.downloads.export_failed');
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

/**
* Bring `subscriptions` and `subscription_items` up to the shape Laravel Cashier v15 expects.
*
* Why: Cashier v15's WebhookController writes to columns (`type`, `stripe_price`, `stripe_product`)
* that don't exist on the legacy (Cashier v9-era) schema. Every Stripe `customer.subscription.*`
* webhook fails with "Unknown column 'type' in 'field list'", so subscription state in our DB
* silently drifts out of sync with Stripe (e.g. past_due never lands).
*
* The Cashier upgrade migrations were never run on this project. This migration brings the schema
* forward in a single, idempotent step and leaves data intact.
*/
return new class extends Migration
{
public function up(): void
{
// ─── subscriptions ──────────────────────────────────────────────────
// Rename `name` → `type` (Cashier v10)
if (Schema::hasColumn('subscriptions', 'name') && ! Schema::hasColumn('subscriptions', 'type')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->renameColumn('name', 'type');
});
}

// Rename `stripe_plan` → `stripe_price` (Cashier v13)
if (Schema::hasColumn('subscriptions', 'stripe_plan') && ! Schema::hasColumn('subscriptions', 'stripe_price')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->renameColumn('stripe_plan', 'stripe_price');
});
}

// Make stripe_price nullable (Cashier v13 — sub may have multiple items, no top-level price)
Schema::table('subscriptions', function (Blueprint $t) {
$t->string('stripe_price')->nullable()->change();
});

// Drop legacy `stripe_active` (Cashier v9 era — replaced by stripe_status)
if (Schema::hasColumn('subscriptions', 'stripe_active')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->dropColumn('stripe_active');
});
}

// Add unique on stripe_id (Cashier expects this; webhook lookups depend on uniqueness).
// Existence-checked rather than try/catch so genuine errors (e.g. duplicate data)
// surface loudly instead of being swallowed.
$subIndexes = collect(DB::select('SHOW INDEX FROM subscriptions'))
->pluck('Key_name')->unique()->all();

if (! in_array('subscriptions_stripe_id_unique', $subIndexes, true)) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->unique('stripe_id');
});
}

// ─── subscription_items ─────────────────────────────────────────────
// Drop the legacy unique key that referenced `stripe_plan` (Cashier v15's index is non-unique
// on (subscription_id, stripe_price); per-row uniqueness is enforced via stripe_id instead).
$itemsIndexes = collect(DB::select('SHOW INDEX FROM subscription_items'))
->pluck('Key_name')->unique()->all();

if (in_array('subscription_items_subscription_id_stripe_plan_unique', $itemsIndexes, true)) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->dropUnique('subscription_items_subscription_id_stripe_plan_unique');
});
}

if (in_array('subscription_items_stripe_id_index', $itemsIndexes, true)) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->dropIndex('subscription_items_stripe_id_index');
});
}

// Rename stripe_plan → stripe_price
if (Schema::hasColumn('subscription_items', 'stripe_plan') && ! Schema::hasColumn('subscription_items', 'stripe_price')) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->renameColumn('stripe_plan', 'stripe_price');
});
}

// Add stripe_product (Cashier v13 — Stripe Product id, separate from Price id).
// Nullable so backfilled rows from old Cashier installs don't violate NOT NULL;
// Cashier's webhook handler always writes it on the next event, so it self-heals.
if (! Schema::hasColumn('subscription_items', 'stripe_product')) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->string('stripe_product')->nullable()->after('stripe_id');
});
}

// Make quantity nullable (Cashier v13 — metered prices may have null quantity)
Schema::table('subscription_items', function (Blueprint $t) {
$t->integer('quantity')->nullable()->change();
});

// Re-add Cashier-shape indexes — existence-checked, not try/catch.
$itemsIndexes = collect(DB::select('SHOW INDEX FROM subscription_items'))
->pluck('Key_name')->unique()->all();

if (! in_array('subscription_items_stripe_id_unique', $itemsIndexes, true)) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->unique('stripe_id');
});
}

if (! in_array('subscription_items_subscription_id_stripe_price_index', $itemsIndexes, true)) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->index(['subscription_id', 'stripe_price']);
});
}

// ─── users (Cashier v13 payment-method column rename) ───────────────
// Cashier v15's ManagesPaymentMethods writes $user->pm_type and $user->pm_last_four
// (renamed from card_brand / card_last_four in Cashier v13). Without this rename,
// every payment-method update or `customer.updated` webhook fatals the same way the
// subscriptions schema bug did.
if (Schema::hasColumn('users', 'card_brand') && ! Schema::hasColumn('users', 'pm_type')) {
Schema::table('users', function (Blueprint $t) {
$t->renameColumn('card_brand', 'pm_type');
});
}

if (Schema::hasColumn('users', 'card_last_four') && ! Schema::hasColumn('users', 'pm_last_four')) {
Schema::table('users', function (Blueprint $t) {
$t->renameColumn('card_last_four', 'pm_last_four');
});
}
}

public function down(): void
{
// Reverse to legacy shape so a rollback is possible. Data preserved.
if (Schema::hasColumn('users', 'pm_last_four')) {
Schema::table('users', function (Blueprint $t) {
$t->renameColumn('pm_last_four', 'card_last_four');
});
}

if (Schema::hasColumn('users', 'pm_type')) {
Schema::table('users', function (Blueprint $t) {
$t->renameColumn('pm_type', 'card_brand');
});
}

Schema::table('subscription_items', function (Blueprint $t) {
try { $t->dropUnique('subscription_items_stripe_id_unique'); } catch (\Throwable $e) {}
try { $t->dropIndex('subscription_items_subscription_id_stripe_price_index'); } catch (\Throwable $e) {}
});

if (Schema::hasColumn('subscription_items', 'stripe_product')) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->dropColumn('stripe_product');
});
}

if (Schema::hasColumn('subscription_items', 'stripe_price')) {
Schema::table('subscription_items', function (Blueprint $t) {
$t->renameColumn('stripe_price', 'stripe_plan');
});
}

Schema::table('subscription_items', function (Blueprint $t) {
$t->integer('quantity')->nullable(false)->change();
$t->index('stripe_id');
});

Schema::table('subscriptions', function (Blueprint $t) {
try { $t->dropUnique('subscriptions_stripe_id_unique'); } catch (\Throwable $e) {}
});

if (! Schema::hasColumn('subscriptions', 'stripe_active')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->unsignedInteger('stripe_active')->default(0);
});
}

if (Schema::hasColumn('subscriptions', 'stripe_price')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->renameColumn('stripe_price', 'stripe_plan');
});
}

if (Schema::hasColumn('subscriptions', 'type')) {
Schema::table('subscriptions', function (Blueprint $t) {
$t->renameColumn('type', 'name');
});
}
}
};
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openlittermap-web",
"version": "5.8.2",
"version": "5.8.4",
"type": "module",
"author": "Seán Lynch",
"license": "GPL v3",
Expand Down
4 changes: 4 additions & 0 deletions readme/changelog/2026-04-08.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
# 2026-04-08

- 5.8.4 — Fix Stripe webhook fatal "Unknown column 'type'" — bring `subscriptions`, `subscription_items` AND `users` up to Cashier v15 shape (rename `subscriptions.name`→`type`, `stripe_plan`→`stripe_price`, drop `stripe_active`, add `stripe_product`, nullable `quantity`, unique `stripe_id`, Cashier-shape indexes; rename `users.card_brand`→`pm_type`, `card_last_four`→`pm_last_four` so payment-method updates don't fatal next). Idempotent migration verified against `olm_postmig_1` (54 prod-like rows preserved, full round-trip). 4 feature tests pin the post-migration write paths used by `Cashier\Http\Controllers\WebhookController::handleCustomerSubscriptionUpdated()`. CSV export tests also gained a `method_exists` guard on `CreateCSVExport::failed()` so the production failure-notification path can't silently break via a rename.
- 5.8.3 — Fix data export fatal (`VerificationStatus` enum stringify in PhpSpreadsheet) — null-safe `$row->verified?->value ?? 0` in `CreateCSVExport::map()`; add `failed()` hook on the export that emails the requester via new `ExportFailed` mail so users aren't left waiting when a job fails; wired `notifyOnFailure()` into user, team and location export dispatch sites; added regression tests covering stringifiable cells for both user and team branches plus the failure-hook email path (10 tests passing).
9 changes: 9 additions & 0 deletions resources/views/emails/downloads/export_failed.blade.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<html>
<body>
<p>Hi,</p>
<p>Unfortunately your OpenLitterMap data export could not be generated. Our team has been notified and is looking into it.</p>
<p>Please try requesting the export again in a little while. If the problem persists, reply to this email and we'll help you sort it out.</p>
<p>Thank you for helping us clean the planet!</p>
<p>— OpenLitterMap</p>
</body>
</html>
Loading
Loading