libra/docs/stories/story-11.1-database-schema-...

7.1 KiB

Story 11.1: Database Schema & Model Updates

Epic Reference

Epic 11: Guest Booking

Story Context

This is the foundational story for Epic 11. All subsequent guest booking stories depend on these database and model changes being complete. This story prepares the data layer to support consultations without a linked user account.

User Story

As a developer, I want to extend the consultation system to support guest bookings, So that consultations can be created for both registered clients and anonymous guests.

Acceptance Criteria

Database Migration

  • user_id column changed to nullable on consultations table
  • guest_name column added (varchar 255, nullable)
  • guest_email column added (varchar 255, nullable)
  • guest_phone column added (varchar 50, nullable)
  • Index added on guest_email for 1-per-day lookup performance
  • Migration is reversible without data loss
  • Existing consultations with user_id unaffected

Model Updates

  • Consultation model updated with new fillable fields
  • isGuest() helper method returns true when user_id is null
  • getClientName() helper returns guest_name or user->name
  • getClientEmail() helper returns guest_email or user->email
  • getClientPhone() helper returns guest_phone or user->phone
  • Model validation ensures either user_id OR guest fields are present

Factory Updates

  • ConsultationFactory updated with guest() state
  • Guest state generates fake guest_name, guest_email, guest_phone
  • Guest state sets user_id to null

Implementation Steps

Step 1: Create Migration

php artisan make:migration add_guest_fields_to_consultations_table

Migration content:

public function up(): void
{
    Schema::table('consultations', function (Blueprint $table) {
        // Make user_id nullable
        $table->foreignId('user_id')->nullable()->change();

        // Add guest fields
        $table->string('guest_name', 255)->nullable()->after('user_id');
        $table->string('guest_email', 255)->nullable()->after('guest_name');
        $table->string('guest_phone', 50)->nullable()->after('guest_email');

        // Index for 1-per-day lookup
        $table->index('guest_email');
    });
}

public function down(): void
{
    Schema::table('consultations', function (Blueprint $table) {
        $table->dropIndex(['guest_email']);
        $table->dropColumn(['guest_name', 'guest_email', 'guest_phone']);
        // Note: Cannot safely restore NOT NULL if guest records exist
    });
}

Step 2: Update Consultation Model

Add to app/Models/Consultation.php:

// Add to fillable array
protected $fillable = [
    // ... existing fields
    'guest_name',
    'guest_email',
    'guest_phone',
];

/**
 * Check if this is a guest consultation (no linked user).
 */
public function isGuest(): bool
{
    return is_null($this->user_id);
}

/**
 * Get the client's display name.
 */
public function getClientName(): string
{
    return $this->isGuest()
        ? $this->guest_name
        : $this->user->name;
}

/**
 * Get the client's email address.
 */
public function getClientEmail(): string
{
    return $this->isGuest()
        ? $this->guest_email
        : $this->user->email;
}

/**
 * Get the client's phone number.
 */
public function getClientPhone(): ?string
{
    return $this->isGuest()
        ? $this->guest_phone
        : $this->user->phone;
}

Step 3: Update Consultation Factory

Add to database/factories/ConsultationFactory.php:

/**
 * Create a guest consultation (no user account).
 */
public function guest(): static
{
    return $this->state(fn (array $attributes) => [
        'user_id' => null,
        'guest_name' => fake()->name(),
        'guest_email' => fake()->unique()->safeEmail(),
        'guest_phone' => fake()->phoneNumber(),
    ]);
}

Step 4: Add Model Validation (Optional Boot Method)

Consider adding validation in model boot to ensure data integrity:

protected static function booted(): void
{
    static::saving(function (Consultation $consultation) {
        // Either user_id or guest fields must be present
        if (is_null($consultation->user_id)) {
            if (empty($consultation->guest_name) ||
                empty($consultation->guest_email) ||
                empty($consultation->guest_phone)) {
                throw new \InvalidArgumentException(
                    'Guest consultations require guest_name, guest_email, and guest_phone'
                );
            }
        }
    });
}

Technical Notes

Foreign Key Consideration

The existing foreign key consultations_user_id_foreign cascades on delete. With nullable user_id:

  • Guest consultations won't be affected by user deletions (they have no user)
  • Client consultations still cascade delete when user is deleted

Query Scopes

Consider adding scopes for filtering:

public function scopeGuests(Builder $query): Builder
{
    return $query->whereNull('user_id');
}

public function scopeClients(Builder $query): Builder
{
    return $query->whereNotNull('user_id');
}

Testing Requirements

Unit Tests

test('consultation can be created as guest', function () {
    $consultation = Consultation::factory()->guest()->create();

    expect($consultation->isGuest())->toBeTrue();
    expect($consultation->user_id)->toBeNull();
    expect($consultation->guest_name)->not->toBeNull();
    expect($consultation->guest_email)->not->toBeNull();
    expect($consultation->guest_phone)->not->toBeNull();
});

test('consultation can be created for client', function () {
    $user = User::factory()->client()->create();
    $consultation = Consultation::factory()->create(['user_id' => $user->id]);

    expect($consultation->isGuest())->toBeFalse();
    expect($consultation->user_id)->toBe($user->id);
});

test('getClientName returns guest name for guest consultation', function () {
    $consultation = Consultation::factory()->guest()->create([
        'guest_name' => 'John Doe',
    ]);

    expect($consultation->getClientName())->toBe('John Doe');
});

test('getClientName returns user name for client consultation', function () {
    $user = User::factory()->client()->create(['name' => 'Jane Smith']);
    $consultation = Consultation::factory()->create(['user_id' => $user->id]);

    expect($consultation->getClientName())->toBe('Jane Smith');
});

test('existing consultations are not affected by migration', function () {
    // Verify existing consultations still work
    $user = User::factory()->client()->create();
    $consultation = Consultation::factory()->create(['user_id' => $user->id]);

    expect($consultation->user)->toBeInstanceOf(User::class);
    expect($consultation->isGuest())->toBeFalse();
});

Dependencies

  • None (foundational story)

Definition of Done

  • Migration created and runs successfully
  • Migration is reversible
  • Consultation model updated with guest fields and helpers
  • Factory updated with guest state
  • All existing consultation tests still pass
  • New unit tests for guest functionality pass
  • Code follows project patterns (Pint formatted)