Tweak lifecycle to make more stateless, fix some styling

This commit is contained in:
Philip Peterson 2026-05-27 21:58:19 -07:00
parent 5ea7e69f5a
commit cd2d59f298
10 changed files with 497 additions and 427 deletions

View file

@ -7,12 +7,39 @@ A Drupal 11 site for Riverside Physical Therapy. Nearly all frontend work lives
## Running locally
```bash
docker compose up # starts app on http://localhost:8080
docker compose up # starts app on http://localhost:8080 (full DB wipe + rebuild from code by default)
docker compose exec app drush cr # clear Drupal cache
npm run watch # Tailwind CSS watcher (run on host, not in container)
npm run build # minified production build
```
### Database & site rebuild behavior
By default, **every** `docker compose up` performs a full database wipe followed by a complete reinstall + rebuild of the site structure from code:
- Drops the database
- Runs `drush site:install standard`
- Enables modules (including `riverside_pt`)
- Runs `drush riverside:rebuild` (the single source of truth for content types, fields, roles, and navigation)
This means the site is **always** built exactly the same way from the code in `riverside_pt.install` and the Drush command. There is no persistent data between restarts unless you opt out.
**Faster iteration (preserve the database):**
```bash
DRUPAL_FAST=1 docker compose up
```
This skips the wipe + `site:install` but still runs `drush riverside:rebuild` and the rest of the startup steps. Use this when you want quicker starts during active development and don't need a completely clean slate.
You can also run the rebuild manually at any time:
```bash
docker compose exec app drush riverside:rebuild
# or the short alias
docker compose exec app drush rrb
```
The custom module directory is volume-mounted, so template/CSS/JS edits are live without rebuilding the Docker image. `settings.php` and `development.services.yml` are also volume-mounted.
## Stack

View file

@ -8,6 +8,8 @@ A Drupal-based appointment scheduling site for booking sessions between patients
docker compose up --build
```
**Default behavior**: Every start performs a full database wipe + rebuilds the entire site from code (content types, fields, menu, etc.). See CLAUDE.md for details and the `DRUPAL_FAST=1` escape hatch for faster iteration.
Admin login: `admin` / `admin` at `/user/login`
## Makefile commands

View file

@ -16,6 +16,10 @@ services:
DEBUG: "${DEBUG:-true}"
POSTMARK_API_KEY: "${POSTMARK_API_KEY:?POSTMARK_API_KEY is required}"
BASE_URL: "${BASE_URL:-http://localhost:8080}"
# DRUPAL_FAST=1 skips the automatic full database wipe + reinstall on every start.
# Use this for faster iteration when you don't want a completely fresh site.
# Default (no flag) = always wipe DB and rebuild everything from code.
DRUPAL_FAST: "${DRUPAL_FAST:-}"
volumes:
- ./web/sites/default/files:/var/www/html/web/sites/default/files
- ./web/sites/default/settings.php:/var/www/html/web/sites/default/settings.php

View file

@ -22,12 +22,15 @@ cd /var/www/html
DRUSH="vendor/bin/drush --root=/var/www/html/web"
HAS_TABLES=$($DRUSH sql:query \
"SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='public' AND table_name='users';" \
2>/dev/null || echo "0")
echo "[entrypoint] Preparing database..."
if [ "$HAS_TABLES" != "1" ]; then
echo "[entrypoint] Fresh database, installing Drupal..."
if [ "${DRUPAL_FAST:-}" = "1" ]; then
echo "[entrypoint] DRUPAL_FAST=1 — skipping database wipe and full site reinstall."
else
echo "[entrypoint] Full rebuild mode (default). Dropping database..."
$DRUSH sql:drop -y || true
echo "[entrypoint] Installing Drupal (standard profile)..."
$DRUSH site:install standard \
--site-name="$SITE_NAME" \
--account-name=admin \
@ -46,19 +49,18 @@ $DRUSH en -y symfony_mailer && \
$DRUSH en -y riverside_pt && \
echo "[entrypoint] riverside_pt enabled." || echo "[entrypoint] WARNING: riverside_pt failed."
echo "[entrypoint] Rebuilding site structure from code (riverside:rebuild)..."
$DRUSH riverside:rebuild || echo "[entrypoint] WARNING: riverside:rebuild encountered an issue."
# Re-assert a few key pieces (cheap and safe).
$DRUSH theme:enable starterkit_theme claro_compact -y && \
$DRUSH config:set system.theme default starterkit_theme -y && \
$DRUSH config:set system.theme admin claro_compact -y && \
echo "[entrypoint] Themes set." || echo "[entrypoint] WARNING: theme enable failed."
$DRUSH config:set system.site page.front /home -y && \
echo "[entrypoint] Front page set." || echo "[entrypoint] WARNING: front page set failed."
if ls /var/www/html/config/sync/*.yml >/dev/null 2>&1; then
echo "[entrypoint] Importing configuration..."
$DRUSH config:import -y && \
echo "[entrypoint] Config imported." || echo "[entrypoint] WARNING: config import failed."
fi
npm run build --prefix /var/www/html >/dev/null 2>&1 && echo "[entrypoint] Tailwind built." || echo "[entrypoint] WARNING: Tailwind build failed."
$DRUSH cache:rebuild >/dev/null 2>&1 && echo "[entrypoint] Cache rebuilt."

View file

@ -10,6 +10,9 @@ module.exports = {
screens: {
'md': '920px',
},
fontFamily: {
hedvig: ['Hedvig Letters Sans', 'sans-serif'],
},
},
},
plugins: [],

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=Hedvig+Letters+Sans:wght@400;500;600&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;

View file

@ -8,146 +8,181 @@ use Drupal\user\Entity\Role;
use Drupal\field\Entity\FieldStorageConfig;
use Drupal\field\Entity\FieldConfig;
/**
* Implements hook_install().
*
* Delegates to the rebuild function so that normal module installs and
* container rebuilds both produce the same result.
*/
function riverside_pt_install() {
// Pass 1: types, roles, and field storages (no inter-dependencies).
NodeType::create([
'type' => 'appointment',
'name' => 'Appointment',
'description' => 'A booking between a Patient and a Provider at a particular time.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
_riverside_pt_rebuild();
}
NodeType::create([
'type' => 'provider_availability',
'name' => 'Provider Availability',
'description' => 'A window of time during which a Provider is available for appointments.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
/**
* Rebuilds the Riverside PT site structure from code.
*
* This function is idempotent and can be safely called multiple times.
* It is the single source of truth for content types, fields, roles,
* and navigation used by the Docker entrypoint.
*/
function _riverside_pt_rebuild(): void {
$entity_type_manager = \Drupal::entityTypeManager();
$storage_node_type = $entity_type_manager->getStorage('node_type');
$storage_role = $entity_type_manager->getStorage('user_role');
$storage_field_storage = $entity_type_manager->getStorage('field_storage_config');
$storage_field_config = $entity_type_manager->getStorage('field_config');
Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
// --- Content types (idempotent) ---
if (!$storage_node_type->load('appointment')) {
NodeType::create([
'type' => 'appointment',
'name' => 'Appointment',
'description' => 'A booking between a Patient and a Provider at a particular time.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
}
FieldStorageConfig::create([
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
if (!$storage_node_type->load('provider_availability')) {
NodeType::create([
'type' => 'provider_availability',
'name' => 'Provider Availability',
'description' => 'A window of time during which a Provider is available for appointments.',
'new_revision' => FALSE,
'display_submitted' => FALSE,
])->save();
}
FieldStorageConfig::create([
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'type' => 'integer',
])->save();
// --- Role (idempotent) ---
if (!$storage_role->load('provider')) {
Role::create(['id' => 'provider', 'label' => 'Provider'])->save();
}
FieldStorageConfig::create([
'field_name' => 'field_service_type',
'entity_type' => 'node',
'type' => 'list_string',
'settings' => [
'allowed_values' => [
'diagnostic' => 'Diagnostic',
'sports_rehab' => 'Sports Rehab',
'pre_post_surgical_rehab' => 'Pre/Post-Surgical Rehab',
'neurological_pt' => 'Neurological PT',
// --- Field storages (idempotent) ---
$field_storages = [
'field_appointment_date' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
],
'field_duration_minutes' => [
'entity_type' => 'node',
'type' => 'integer',
],
'field_service_type' => [
'entity_type' => 'node',
'type' => 'list_string',
'settings' => [
'allowed_values' => [
'diagnostic' => 'Diagnostic',
'sports_rehab' => 'Sports Rehab',
'pre_post_surgical_rehab' => 'Pre/Post-Surgical Rehab',
'neurological_pt' => 'Neurological PT',
],
],
],
])->save();
'field_provider' => [
'entity_type' => 'node',
'type' => 'entity_reference',
'settings' => ['target_type' => 'user'],
],
'field_start_datetime' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
],
'field_end_datetime' => [
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
],
];
FieldStorageConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
'type' => 'entity_reference',
'settings' => ['target_type' => 'user'],
])->save();
foreach ($field_storages as $field_name => $definition) {
if (!$storage_field_storage->load("node.$field_name")) {
FieldStorageConfig::create([
'field_name' => $field_name,
] + $definition)->save();
}
}
FieldStorageConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
FieldStorageConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'type' => 'datetime',
'settings' => ['datetime_type' => 'datetime'],
])->save();
// Clear field definition cache so FieldConfig::preSave() can find the storages.
// Clear field definition cache so FieldConfig creation can see the storages.
\Drupal::service('entity_field.manager')->clearCachedFieldDefinitions();
// Pass 2: field configs (depend on storages being in the DB).
FieldConfig::create([
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Appointment Date',
'required' => TRUE,
])->save();
FieldConfig::create([
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Duration (Minutes)',
'required' => TRUE,
])->save();
FieldConfig::create([
'field_name' => 'field_service_type',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Service Type',
'required' => TRUE,
])->save();
FieldConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Provider',
'required' => TRUE,
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
// --- Field configs (idempotent) ---
$field_configs = [
// Appointment bundle
'node.appointment.field_appointment_date' => [
'field_name' => 'field_appointment_date',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Appointment Date',
'required' => TRUE,
],
'node.appointment.field_duration_minutes' => [
'field_name' => 'field_duration_minutes',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Duration (Minutes)',
'required' => TRUE,
],
'node.appointment.field_service_type' => [
'field_name' => 'field_service_type',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Service Type',
'required' => TRUE,
],
'node.appointment.field_provider' => [
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'appointment',
'label' => 'Provider',
'required' => TRUE,
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
],
])->save();
FieldConfig::create([
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Provider',
'required' => TRUE,
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
// Provider availability bundle
'node.provider_availability.field_provider' => [
'field_name' => 'field_provider',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Provider',
'required' => TRUE,
'settings' => [
'handler' => 'default:user',
'handler_settings' => [
'filter' => ['type' => '_role', 'role' => ['provider' => 'provider']],
],
],
],
])->save();
'node.provider_availability.field_start_datetime' => [
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Start',
'required' => TRUE,
],
'node.provider_availability.field_end_datetime' => [
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'End',
'required' => TRUE,
],
];
FieldConfig::create([
'field_name' => 'field_start_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'Start',
'required' => TRUE,
])->save();
FieldConfig::create([
'field_name' => 'field_end_datetime',
'entity_type' => 'node',
'bundle' => 'provider_availability',
'label' => 'End',
'required' => TRUE,
])->save();
foreach ($field_configs as $field_id => $definition) {
if (!$storage_field_config->load($field_id)) {
FieldConfig::create($definition)->save();
}
}
// Navigation and basic site settings (these are intentionally destructive on menu).
try {
_riverside_pt_build_navigation();
}

View file

@ -0,0 +1,43 @@
<?php
namespace Drupal\riverside_pt\Commands;
use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drush\Attributes as CLI;
use Drush\Commands\DrushCommands;
use Symfony\Component\DependencyInjection\ContainerInterface;
/**
* Drush commands for Riverside PT site management.
*/
final class RiversidePtCommands extends DrushCommands implements ContainerInjectionInterface {
public static function create(ContainerInterface $container): self {
return new self();
}
/**
* Rebuilds the entire Riverside PT site structure from code.
*
* This is the command used by the Docker entrypoint on every start
* (unless DRUPAL_FAST=1 is passed).
*/
#[CLI\Command(name: 'riverside:rebuild', aliases: ['rrb'])]
#[CLI\Usage(name: 'drush riverside:rebuild', description: 'Rebuild content types, fields, roles, and navigation from code. Safe to run repeatedly.')]
public function rebuild(): void {
$this->output()->writeln('<info>Rebuilding Riverside PT site structure from code...</info>');
// Make the helper functions from riverside_pt.install available.
\Drupal::moduleHandler()->loadInclude('riverside_pt', 'install');
if (!function_exists('_riverside_pt_rebuild')) {
$this->logger()->error('Could not load _riverside_pt_rebuild().');
return;
}
_riverside_pt_rebuild();
$this->output()->writeln('<info>Rebuild complete.</info>');
$this->logger()->success('Riverside PT structure has been rebuilt from code.');
}
}

View file

@ -46,8 +46,8 @@
<section class="py-16 px-6 bg-white">
<div class="max-w-[1040px] mx-auto mb-12">
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold text-center mb-3">Bringing Relief</p>
<h2 class="text-[2.75rem] font-serif font-normal text-gray-900 leading-tight">Our Wide Range of Physical Therapy Services</h2>
<p class="text-sm tracking-widest uppercase text-[#306f8e] font-semibold text-center mb-2">Bringing Relief</p>
<h2 class="text-[2.25rem] font-serif font-normal text-gray-900 leading-tight">Our Wide Range of Physical Therapy Services</h2>
</div>
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6 max-w-[1040px] mx-auto">
<div class="flex flex-col border border-[#b8d4dc] bg-white overflow-hidden">
@ -96,8 +96,8 @@
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">15</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Years Open</p>
</div>
<div>
<p class="text-[4.5rem] font-serif text-[#306f8e] leading-none">300</p>
<div class="font-hedvig">
<p class="text-[4.5rem] text-[#306f8e] leading-none">300</p>
<p class="text-xs tracking-widest uppercase text-[#306f8e] font-semibold mt-2">Patients Served</p>
</div>
</div>