Commit e22cd9e4 authored by Mahmoud Aglan's avatar Mahmoud Aglan

fix(branch): split a programme two branches share, and give operators a way to check a client

Everything so far was validated against one client database. The tenants are not
all shaped like OC-Sport, every one of them runs `migrate --force` on its next
container start, and there is no staging in between — so the risk worth chasing
was never OC-Sport, it was the tenant I cannot see.

Reproduced by building one. On a database where a programme's groups run at two
branches, 2026_09_13_000002 declines to guess and falls through to the
main-branch fallback. The group at the other branch keeps pointing at a
programme that branch can no longer see: its name renders blank, and
PricingService cannot find a base price for it, so the enrolment cannot be
billed at all. `لا يوجد سعر محدد`, on a group that worked the day before. Silent,
and caused by the migration rather than found by it.

  - 2026_09_13_000003 replicates instead of picking a winner. The programme keeps
    its identity where it was pinned, every other branch using it gets a copy of
    its own, and that branch's groups, enrolments, active prices and product
    bundles are repointed at the copy. Nothing is deleted and nothing changes
    branch. Two branches running "فريق 2018" now have two rows that can diverge,
    which is the point — the same answer the product already gives for groups.
    Verified on a constructed tenant carrying the fault, and a no-op on OC-Sport.

  - `php artisan branch:audit` reports what is silent in the UI: strictly-scoped
    rows with no branch (not a leak — a disappearance, present in SQL and on no
    screen), children in a different branch from their parent, and programmes
    with live enrolments and no active price. Exits non-zero so it can gate a
    deploy check. On OC-Sport it finds one genuine pre-existing problem —
    programme #32 has six active enrolments and no price at all — and no branch
    integrity faults.

  - BranchValidationRulesTest closes a gap in the suite itself: every other
    branch test needs a restored Postgres tenant and skips without one, so on an
    ordinary `php artisan test` none of them run. This one reads source, so it
    runs everywhere — banning a raw `exists:` rule on a branch-owned table (they
    compile to a raw query that accepts any id in the academy, and the property
    feeding one is usually browser-settable), and failing when a model carries
    branch_id in $fillable without declaring how it is scoped.

  - Event now declares BRANCH_SCOPE_EXEMPT with its reasoning, so that being
    academy-wide reads as a decision rather than as a model somebody forgot.

Standard suite: 231 tests, no failures. Against a restored tenant: 28 branch
tests, 23,093 assertions — and the same suite passes against the constructed
tenant that carried the split fault.
Co-Authored-By: 's avatarClaude Opus 5 (1M context) <noreply@anthropic.com>
parent bf549c87
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Check one client's data against the branch boundary, and say what is wrong.
*
* php artisan branch:audit
*
* Branch isolation is enforced in the model layer, so the code is the same
* everywhere — but the *data* is not, and the data is what decides whether the
* enforcement helps or hurts. Two shapes cause real damage and neither raises an
* error on its own:
*
* A strictly-scoped row with no branch is invisible from every branch at once.
* It is not a leak, it is a disappearance: the record is still there, still
* counted in the database, and on no screen in the product.
*
* A child pointing at a parent in another branch renders blank and, for a
* group whose programme is elsewhere, cannot be priced — PricingService treats
* a missing base price as a hard failure, so the sale stops.
*
* The migrations fix both for the shapes they can reach. This exists because
* there are ~30 client databases, no staging between a push and all of them, and
* no way to inspect them from a laptop. Run it against a tenant after a deploy
* and it will say plainly whether that tenant is intact.
*
* Exits non-zero when anything is found, so it can gate a deploy check.
*/
class AuditBranchIntegrity extends Command
{
protected $signature = 'branch:audit {--json : Emit machine-readable output}';
protected $description = 'Report rows that belong to no branch, or point at another branch';
/**
* Tables where every row belongs to exactly one branch. A null here is the
* disappearance described above.
*/
private const STRICT = [
'participants', 'training_groups', 'enrollments', 'training_sessions',
'attendance_records', 'evaluations', 'waitlists', 'assignments',
'invoices', 'payments', 'transactions', 'expenses', 'cash_sessions',
'pos_transactions', 'facilities', 'space_reservations', 'space_layouts',
'inventory_levels', 'inventory_movements', 'stock_counts',
'training_programs', 'base_prices', 'products', 'warehouses',
'documents', 'service_requests', 'payslips',
];
/**
* Child → parent links that must agree on the branch.
*
* [child table, child FK, parent table, what breaks when they disagree]
*/
private const LINKS = [
['training_groups', 'training_program_id', 'training_programs', 'the group cannot be priced or renewed'],
['enrollments', 'training_group_id', 'training_groups', 'the enrolment is filed away from its group'],
['training_sessions', 'training_group_id', 'training_groups', 'the session is missing from the schedule'],
['attendance_records', 'training_session_id', 'training_sessions', 'attendance is missing from the register'],
['inventory_levels', 'warehouse_id', 'warehouses', 'stock shows against the wrong shelf'],
['inventory_movements', 'warehouse_id', 'warehouses', 'the movement is missing from the ledger'],
['stock_counts', 'warehouse_id', 'warehouses', 'the count cannot be finalised'],
['space_reservations', 'facility_id', 'facilities', 'the booking is invisible on the pitch'],
['cash_sessions', 'branch_id', 'branches', 'the till belongs to no branch'],
];
public function handle(): int
{
$findings = array_merge($this->unattributedRows(), $this->crossBranchLinks(), $this->unpriceable());
if ($this->option('json')) {
$this->line(json_encode($findings, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $findings === [] ? self::SUCCESS : self::FAILURE;
}
if ($findings === []) {
$this->info('✓ Branch integrity intact — every row has a branch, and every child agrees with its parent.');
return self::SUCCESS;
}
$this->newLine();
$this->error(count($findings).' problem(s) found:');
$this->newLine();
$this->table(
['Kind', 'Where', 'Rows', 'What it costs'],
array_map(fn ($f) => [$f['kind'], $f['where'], $f['rows'], $f['impact']], $findings)
);
$this->newLine();
$this->line('See docs/agent-rules/19-branch-isolation.md. Rows with no branch are');
$this->line('usually a migration that ran before its backfill; cross-branch links');
$this->line('usually mean a catalogue row needs splitting per branch.');
return self::FAILURE;
}
/** @return array<int, array{kind: string, where: string, rows: int, impact: string}> */
private function unattributedRows(): array
{
$out = [];
foreach (self::STRICT as $table) {
if (! Schema::hasTable($table) || ! Schema::hasColumn($table, 'branch_id')) {
continue;
}
$count = DB::table($table)
->whereNull('branch_id')
->when(
Schema::hasColumn($table, 'deleted_at'),
fn ($q) => $q->whereNull('deleted_at')
)
->count();
if ($count > 0) {
$out[] = [
'kind' => 'no branch',
'where' => $table,
'rows' => $count,
'impact' => 'invisible from every branch',
];
}
}
return $out;
}
/** @return array<int, array{kind: string, where: string, rows: int, impact: string}> */
private function crossBranchLinks(): array
{
$out = [];
foreach (self::LINKS as [$child, $fk, $parent, $impact]) {
if (! Schema::hasTable($child) || ! Schema::hasTable($parent)) {
continue;
}
if (! Schema::hasColumn($child, 'branch_id') || ! Schema::hasColumn($parent, 'branch_id')) {
continue;
}
if (! Schema::hasColumn($child, $fk)) {
continue;
}
// `branches` is its own parent for the cash-session check; comparing
// a branch to itself would be nonsense, so that link only tests that
// the FK resolves.
$parentKey = $parent === 'branches' ? 'id' : 'id';
$query = DB::table($child.' as c')
->join($parent.' as p', 'p.'.$parentKey, '=', 'c.'.$fk)
->whereNotNull('c.branch_id');
if ($parent === 'branches') {
$query->whereColumn('c.branch_id', '!=', 'p.id');
} else {
$query->whereNotNull('p.branch_id')->whereColumn('c.branch_id', '!=', 'p.branch_id');
}
if (Schema::hasColumn($child, 'deleted_at')) {
$query->whereNull('c.deleted_at');
}
$count = $query->count();
if ($count > 0) {
$out[] = [
'kind' => 'crosses branches',
'where' => $child.' → '.$parent,
'rows' => $count,
'impact' => $impact,
];
}
}
return $out;
}
/**
* A programme with live enrolments and no active price in its own branch.
*
* Not strictly a branch-integrity problem — a programme can simply have been
* set up without a price — but it is the failure the branch work is most
* often blamed for, because the symptom is identical: `لا يوجد سعر محدد` at
* the till. Worth naming so the two are told apart.
*/
private function unpriceable(): array
{
if (! Schema::hasTable('base_prices') || ! Schema::hasTable('enrollments')) {
return [];
}
$count = DB::table('training_programs as p')
->join('training_groups as g', 'g.training_program_id', '=', 'p.id')
->join('enrollments as e', 'e.training_group_id', '=', 'g.id')
->where('e.status', 'active')
->whereNull('g.deleted_at')
->whereNotExists(function ($q) {
$q->selectRaw('1')->from('base_prices as bp')
->whereColumn('bp.priceable_id', 'p.id')
->where('bp.priceable_type', 'like', '%TrainingProgram')
->where('bp.is_active', true)
->whereNull('bp.deleted_at')
->whereColumn('bp.branch_id', 'g.branch_id');
})
->distinct()
->count('p.id');
return $count > 0
? [[
'kind' => 'no price',
'where' => 'training_programs',
'rows' => $count,
'impact' => 'active enrolments that cannot be billed',
]]
: [];
}
}
...@@ -21,6 +21,20 @@ class Event extends Model ...@@ -21,6 +21,20 @@ class Event extends Model
{ {
use BelongsToAcademy, HasUuid, SoftDeletes; use BelongsToAcademy, HasUuid, SoftDeletes;
/**
* Deliberately not branch-scoped.
*
* An event is an academy-wide occasion — a tournament, an open day, a trial
* week — and is one of the few operational things that genuinely belongs to
* the academy rather than to one of its branches. The `branch_id` column
* survives from the first isolation pass; dropping it would be destructive
* and a per-branch event is plausible later, but nothing reads it today.
*
* Named rather than merely absent so that BranchValidationRulesTest can
* tell a decision from an oversight.
*/
public const BRANCH_SCOPE_EXEMPT = 'academy-wide by design; see docs/agent-rules/19';
protected $fillable = [ protected $fillable = [
'academy_id', 'branch_id', 'academy_id', 'branch_id',
'title', 'title',
......
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
/**
* Give each branch its own copy of a programme two branches were sharing.
*
* 2026_09_13_000002 moved programmes into the strict bucket and backfilled each
* one to the single branch its groups run at. Where a programme's groups ran at
* *two* branches it declined to guess and fell through to the main-branch
* fallback — which is the wrong answer, and quietly so: the group at the other
* branch keeps pointing at a programme that branch can no longer see. Its name
* renders blank, and PricingService cannot find a base price for it, so the
* enrolment cannot be billed at all. `لا يوجد سعر محدد`, on a group that was
* working the day before.
*
* OC-Sport does not have this shape — no group there points at another branch's
* programme — which is exactly why it needs writing down rather than leaving to
* the next person to discover. Every client runs `migrate --force` on its next
* container start with no staging in between, and the tenants are not all shaped
* like the one that was available to test against.
*
* So: replicate rather than pick a winner. The programme keeps its identity at
* the branch it was pinned to, and every other branch using it gets a copy of
* its own, with that branch's groups, enrolments, prices and product bundles
* repointed at the copy. Nothing is deleted and nothing moves branch; the only
* new rows are the copies.
*
* This is deliberately not clever about "the same programme". Two branches
* running "فريق 2018" now have two rows, and editing one does not edit the
* other. That is the point — it is what a branch being its own installation
* means, and it is the same answer the product already gives for groups.
*/
return new class extends Migration
{
public function up(): void
{
foreach ($this->orphanedPairs() as $pair) {
$this->giveBranchItsOwnCopy((int) $pair->programme_id, (int) $pair->branch_id);
}
}
/**
* Every (programme, branch) pair where a group sits in one branch and its
* programme in another.
*
* Driven off the groups rather than off the programmes, because a group is
* the thing that breaks: an unused programme filed at the wrong branch is
* untidy, but a group whose programme is invisible cannot be billed.
*/
private function orphanedPairs(): array
{
if (! Schema::hasTable('training_groups') || ! Schema::hasColumn('training_groups', 'branch_id')) {
return [];
}
return DB::select("
SELECT DISTINCT g.training_program_id AS programme_id, g.branch_id
FROM training_groups g
JOIN training_programs p ON p.id = g.training_program_id
WHERE g.branch_id IS NOT NULL
AND p.branch_id IS NOT NULL
AND g.branch_id <> p.branch_id
AND g.deleted_at IS NULL
ORDER BY g.training_program_id, g.branch_id
");
}
private function giveBranchItsOwnCopy(int $programmeId, int $branchId): void
{
$programme = DB::table('training_programs')->find($programmeId);
if (! $programme) {
return;
}
$columns = Schema::getColumnListing('training_programs');
$copy = [];
foreach ($columns as $column) {
if ($column === 'id') {
continue;
}
$copy[$column] = $programme->{$column} ?? null;
}
$copy['uuid'] = (string) Str::uuid();
$copy['branch_id'] = $branchId;
$copy['created_at'] = now();
$copy['updated_at'] = now();
// (academy_id, slug) is unique. Suffixing with the branch id rather than
// its code because a code is nullable and a slug collision here would
// abort the whole deploy.
if (array_key_exists('slug', $copy)) {
$copy['slug'] = $this->uniqueSlug((string) $programme->slug, (int) $programme->academy_id, $branchId);
}
$newId = (int) DB::table('training_programs')->insertGetId($copy);
// The groups that made this necessary, and everything hanging off them.
DB::table('training_groups')
->where('training_program_id', $programmeId)
->where('branch_id', $branchId)
->update(['training_program_id' => $newId]);
if (Schema::hasColumn('enrollments', 'training_program_id')) {
DB::table('enrollments')
->where('training_program_id', $programmeId)
->where('branch_id', $branchId)
->update(['training_program_id' => $newId]);
}
$this->copyPrices($programmeId, $newId, $branchId, (int) $programme->academy_id);
$this->copyBundledProducts($programmeId, $newId, (int) $programme->academy_id);
}
private function uniqueSlug(string $slug, int $academyId, int $branchId): string
{
$candidate = Str::limit($slug, 200, '').'-b'.$branchId;
$suffix = 1;
while (DB::table('training_programs')
->where('academy_id', $academyId)
->where('slug', $candidate)
->exists()
) {
$candidate = Str::limit($slug, 195, '').'-b'.$branchId.'-'.$suffix++;
}
return $candidate;
}
/**
* A copy with no price is a copy that cannot be sold, so the prices come
* with it. Only the active ones: a superseded price belongs to the history
* of the original row, not to a branch that never charged it.
*/
private function copyPrices(int $fromId, int $toId, int $branchId, int $academyId): void
{
if (! Schema::hasTable('base_prices')) {
return;
}
$columns = Schema::getColumnListing('base_prices');
$prices = DB::table('base_prices')
->where('priceable_type', 'like', '%TrainingProgram')
->where('priceable_id', $fromId)
->where('is_active', true)
->whereNull('deleted_at')
->get();
foreach ($prices as $price) {
$copy = [];
foreach ($columns as $column) {
if ($column === 'id') {
continue;
}
$copy[$column] = $price->{$column} ?? null;
}
$copy['uuid'] = (string) Str::uuid();
$copy['priceable_id'] = $toId;
$copy['branch_id'] = $branchId;
$copy['academy_id'] = $academyId;
$copy['created_at'] = now();
$copy['updated_at'] = now();
DB::table('base_prices')->insert($copy);
}
}
private function copyBundledProducts(int $fromId, int $toId, int $academyId): void
{
if (! Schema::hasTable('program_products')) {
return;
}
$rows = DB::table('program_products')->where('training_program_id', $fromId)->get();
foreach ($rows as $row) {
$exists = DB::table('program_products')
->where('training_program_id', $toId)
->where('product_id', $row->product_id)
->exists();
if ($exists) {
continue;
}
DB::table('program_products')->insert([
'academy_id' => $academyId,
'training_program_id' => $toId,
'product_id' => $row->product_id,
'is_required' => $row->is_required,
'quantity' => $row->quantity,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
/**
* Not reversible, and deliberately so. The copies are now the programmes
* those branches' groups point at, with their own enrolments and prices
* hanging off them; merging them back would have to pick which of two
* diverged rows is the real one, and there is no answer to that question.
*/
public function down(): void
{
}
};
...@@ -226,3 +226,52 @@ never got backfilled — fix the data, not the boundary. ...@@ -226,3 +226,52 @@ never got backfilled — fix the data, not the boundary.
5. Add the model to `tests/Feature/BranchIsolationTest.php`, in `STRICT` or 5. Add the model to `tests/Feature/BranchIsolationTest.php`, in `STRICT` or
`SHARED`. That test runs against a restored tenant and compares what Eloquent `SHARED`. That test runs against a restored tenant and compares what Eloquent
returns against what raw SQL says is in the branch. returns against what raw SQL says is in the branch.
---
## Checking a client after a deploy
There are ~30 client databases, one push deploys to all of them, and there is no
staging in between. Enforcement is in the code, so it is identical everywhere —
but the *data* is not, and the data decides whether the enforcement helps or
hurts.
```bash
php artisan branch:audit # human-readable; exits non-zero on a problem
php artisan branch:audit --json # for a deploy gate
```
It reports three things, all of which are silent in the UI:
- **A strictly-scoped row with no branch.** Not a leak — a disappearance. The
record is in the database, counted by SQL, and on no screen in the product.
- **A child in a different branch from its parent.** Renders blank; for a group
whose programme sits elsewhere it also means no base price, and no base price
is a hard failure that stops the sale.
- **A programme with live enrolments and no active price**, which produces the
same `لا يوجد سعر محدد` at the till as the case above and is worth telling
apart from it.
`2026_09_13_000003` fixes the second case for programmes by giving each branch
its own copy — rather than picking a winner and orphaning the loser's groups —
and repointing that branch's groups, enrolments, prices and product bundles at
it. Two branches running the same programme end up with two rows that can then
diverge, which is the point: a branch is its own installation.
## The tests, and which one runs when
| Test | Needs a tenant? | Catches |
|---|---|---|
| `BranchValidationRulesTest` | no | a raw `exists:` rule, or a model with `branch_id` and no declared scope |
| `BranchIsolationTest` | yes | a model returning the wrong branch's rows |
| `BranchScopedScreensTest` | yes | another branch's records reaching a page |
| `DashboardQueriesRespectBranchTest` | yes | a total computed from the wrong branch |
| `PricingSurvivesBranchScopeTest` | yes | scoping having taken a price away |
Only the first runs on a plain `php artisan test`. The rest skip without a
restored Postgres copy of a client, so a green suite on a laptop is not evidence
about the branch boundary — restore one and run them:
```bash
DB_CONNECTION=pgsql DB_DATABASE=oc_sport_test ./vendor/bin/phpunit --filter Branch
```
<?php
namespace Tests\Feature;
use Tests\TestCase;
/**
* Read the source and refuse an `exists:` rule that does not name a branch.
*
* Every other branch test in this suite needs a restored Postgres tenant and
* skips without one — which means on an ordinary `php artisan test`, and in any
* CI that has no client database, none of them run. This one reads files, so it
* runs everywhere, always.
*
* It guards the one hole the global scope cannot cover on the write path.
* `exists:` and `Rule::exists()` compile to a raw table query: no academy scope,
* no branch scope, nothing. Point one at a branch-owned table and it accepts any
* id in the academy — and the property feeding it is usually a plain public
* Livewire property, which the browser sets. That is not a display leak; it
* files a payment, an enrolment or a stock movement against another branch, and
* unlike a leak it cannot be fixed by reloading the page.
*
* The rule enforced here: a validation rule on a branch-owned table must be
* written as Rule::exists(...) with a branch constraint in the same statement.
* The bare string form (`'exists:products,id'`) is banned outright for those
* tables, because there is nowhere in it to put one.
*/
class BranchValidationRulesTest extends TestCase
{
/** Tables whose rows belong to a branch, so an id alone is not enough. */
private const BRANCH_OWNED = [
'participants', 'training_groups', 'training_programs', 'enrollments',
'facilities', 'invoices', 'payments', 'expenses', 'products',
'warehouses', 'base_prices', 'pricing_rules', 'promotions', 'kits',
'training_sessions', 'space_layouts', 'cash_sessions', 'stock_counts',
'inventory_levels', 'purchase_orders', 'receipt_templates',
];
/** @return string[] */
private function sourceFiles(): array
{
$files = [];
foreach (['app/Livewire', 'app/Http/Controllers', 'app/Domain'] as $dir) {
$path = base_path($dir);
if (! is_dir($path)) {
continue;
}
$it = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($path));
foreach ($it as $file) {
if ($file->isFile() && $file->getExtension() === 'php') {
$files[] = $file->getPathname();
}
}
}
sort($files);
return $files;
}
public function test_no_bare_exists_rule_targets_a_branch_owned_table(): void
{
$offenders = [];
foreach ($this->sourceFiles() as $file) {
$lines = file($file);
foreach ($lines as $i => $line) {
// Comments explain the rule; they are not the rule.
$code = trim($line);
if (str_starts_with($code, '//') || str_starts_with($code, '*')) {
continue;
}
foreach (self::BRANCH_OWNED as $table) {
if (preg_match('/[\'"]exists:'.$table.'\b/', $line)) {
$offenders[] = $this->relative($file).':'.($i + 1).' — '.trim($line);
}
}
}
}
$this->assertSame(
[],
$offenders,
"A bare `exists:` rule on a branch-owned table accepts any id in the academy.\n"
."Use Rule::exists(...)->where('branch_id', \$this->getActiveBranchId()) instead:\n\n"
.implode("\n", $offenders)
);
}
public function test_every_rule_exists_on_a_branch_owned_table_constrains_the_branch(): void
{
$offenders = [];
foreach ($this->sourceFiles() as $file) {
$source = file_get_contents($file);
$lines = explode("\n", $source);
foreach (self::BRANCH_OWNED as $table) {
$pattern = '/Rule::exists\(\s*[\'"]'.$table.'[\'"]/';
foreach ($lines as $i => $line) {
if (! preg_match($pattern, $line)) {
continue;
}
// The constraint may be chained across several lines, and on
// a couple of screens it is applied to a $rule variable
// further down. A generous window is the right trade: a
// false negative here is a missed leak, a false positive is
// a broken build over nothing.
$window = implode("\n", array_slice($lines, max(0, $i - 12), 26));
if (stripos($window, 'branch') !== false) {
continue;
}
$offenders[] = $this->relative($file).':'.($i + 1).' — '.trim($line);
}
}
}
$this->assertSame(
[],
$offenders,
"These Rule::exists() calls name a branch-owned table but no branch:\n\n"
.implode("\n", $offenders)
);
}
/**
* A model that owns a branch must declare which kind it is, so that adding
* one and forgetting the trait is a failing test rather than a silent leak.
*/
public function test_every_model_with_a_branch_column_is_classified(): void
{
$unclassified = [];
$models = glob(base_path('app/Domain/*/Models/*.php'));
foreach ($models as $file) {
$source = file_get_contents($file);
// A branch_id inside $fillable is the model claiming the column
// as its own. Anywhere else — a withPivot() on a role, a foreign key
// named in a relation — is a reference to somebody else's branch and
// says nothing about this model.
if (! preg_match('/\$fillable\s*=\s*\[(.*?)\];/s', $source, $m)) {
continue;
}
if (! str_contains($m[1], "'branch_id'")) {
continue;
}
$classified = str_contains($source, 'BelongsToBranch')
|| str_contains($source, 'ScopedThroughBranch')
// An explicit, reasoned exemption is a classification too — the
// point of this test is that nobody arrives here by accident.
|| str_contains($source, 'BRANCH_SCOPE_EXEMPT')
// Branch itself is the subject rather than the object.
|| str_contains($source, 'class Branch ');
if (! $classified) {
$unclassified[] = $this->relative($file);
}
}
$this->assertSame(
[],
$unclassified,
"These models carry branch_id but declare no branch scope — see docs/agent-rules/19:\n\n"
.implode("\n", $unclassified)
);
}
private function relative(string $path): string
{
return str_replace(base_path().'/', '', $path);
}
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment