# Task 6: Job Collection and Salesperson Access

This implementation reuses existing job, proposal, collector, booking, and status fields. No database schema change is required.

## Implemented integration

- `JobAccessPolicy` is the shared backend policy for cross-shift visibility, application eligibility, and booking release ownership.
- `JobPostRepository` applies the policy to both list and single-job authorization.
- `JobPostService` applies the policy when booking or releasing a booking.
- `ProposalService` sets the authenticated applicant as proposal owner and does not modify `collector_id`.
- `jobEligibility.js` is the shared frontend helper used to hide unavailable booking/create actions.

## Business rules

1. A user with the existing job/proposal create permission can collect a job.
2. The job collector is stored in `job_posts.collector_id` and must never be overwritten by an application action.
3. Any authorized salesperson can see an eligible job, regardless of shift.
4. The logged-in salesperson who applies/submits becomes the application owner (`sales_person_id`).
5. A booking can be released only by the booking owner or an admin.
6. A job is not eligible when its lifecycle status is `submitted`, `prospect`, or `deleted`, or its availability status is `hired`, `suspended`, `private`, or `not_available`.

## Reusable backend policy

Create a dedicated service such as `Modules/Upwork/app/Services/JobAccessPolicy.php`. Repositories, booking services, and proposal services should call this policy instead of keeping separate rules.

```php
<?php

namespace Modules\Upwork\Services;

use App\Models\User;
use Modules\Upwork\Enums\JobStatus;
use Modules\Upwork\Models\JobPost;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Auth\Access\AuthorizationException;

final class JobAccessPolicy
{
    private const UNAVAILABLE_JOB_STATUSES = [
        'hired', 'suspended', 'private', 'not_available',
    ];

    public function visibleJobsFor(Builder $query, User $user): Builder
    {
        if ($user->is_admin || $user->hasRole('admin')) {
            return $query;
        }

        if ($user->hasRole('sales_person')) {
            // Shift is reporting metadata, not a visibility restriction.
            return $query->where(function (Builder $jobs) use ($user) {
                $jobs->whereIn('status', [JobStatus::PENDING->value, JobStatus::ACTIVE->value])
                    ->where(function (Builder $availability) {
                        $availability->whereNull('job_status')
                            ->orWhereNotIn('job_status', self::UNAVAILABLE_JOB_STATUSES);
                    })
                    ->orWhere('collector_id', $user->id)
                    ->orWhereHas('proposals', fn (Builder $proposals) =>
                        $proposals->where('sales_person_id', $user->id)
                    );
            });
        }

        if ($user->hasRole('writer')) {
            return $query->whereHas('proposals', fn (Builder $proposals) =>
                $proposals->where('writer_id', $user->id)
            );
        }

        return $query->whereRaw('1 = 0');
    }

    public function canApply(JobPost $job, User $user): bool
    {
        if ($user->is_admin || $user->hasRole('admin')) {
            return $this->isEligible($job);
        }

        return $user->hasRole('sales_person') && $this->isEligible($job);
    }

    public function canReleaseBooking(JobPost $job, User $user): bool
    {
        return $user->is_admin
            || $user->hasRole('admin')
            || (int) $job->booked_by === (int) $user->id;
    }

    public function assertCanApply(JobPost $job, User $user): void
    {
        if (!$this->canApply($job, $user)) {
            throw new AuthorizationException('This job is not available for application.');
        }

        if ($job->isCurrentlyBooked($user->id)) {
            throw new AuthorizationException('This job is currently booked by another salesperson.');
        }
    }

    private function isEligible(JobPost $job): bool
    {
        $status = $job->status instanceof \BackedEnum ? $job->status->value : $job->status;
        $jobStatus = $job->job_status instanceof \BackedEnum ? $job->job_status->value : $job->job_status;

        return !in_array($status, [
            JobStatus::SUBMITTED->value,
            JobStatus::PROSPECT->value,
            JobStatus::DELETED->value,
        ], true) && !in_array($jobStatus, self::UNAVAILABLE_JOB_STATUSES, true);
    }
}
```

## Reusable ownership rule

Call this immediately before a proposal/application is created or submitted. Do not update `collector_id`.

```php
$policy->assertCanApply($job, $request->user());

$proposalData['sales_person_id'] = $request->user()->id;
$proposalData['job_post_id'] = $job->id;
// Do not set $job->collector_id here.
```

## Reusable booking rule

```php
if (!$policy->canReleaseBooking($job, $request->user())) {
    abort(403, 'Only the booking owner or an admin can release this booking.');
}

$job->update([
    'is_booked' => false,
    'booked_by' => null,
    'booked_at' => null,
]);
```

## Frontend helper

Use a shared helper for action visibility; backend validation remains the source of truth.

```js
const BLOCKED_JOB_STATUSES = new Set([
  'hired', 'suspended', 'private', 'not_available',
]);

export function canApplyToJob(job) {
  return !['submitted', 'prospect', 'deleted'].includes(job.status)
    && !BLOCKED_JOB_STATUSES.has(job.job_status);
}

export function canReleaseJobBooking(job, currentUser) {
  return Boolean(currentUser?.is_admin)
    || Number(job.booked_by) === Number(currentUser?.id);
}
```

## Integration checklist

- Apply `visibleJobsFor()` in job list and detail authorization.
- Apply `assertCanApply()` in proposal create/submit and booking endpoints.
- Set proposal/application `sales_person_id` from the authenticated user only.
- Retain existing `collector_id` and shift data unchanged.
- Use the frontend helper in Job Action Menu, preview, and upload drawers.
- Test cross-shift visibility, booking conflict, owner-only release, and unavailable statuses.
