# Hostfully → PropDesk Import Runbook

**Audience:** Gil + anyone onboarding a new property-management company onto PropDesk who is currently on Hostfully.
**Last updated:** 2026-05-06
**Status:** Production-ready. Successfully migrated Willow Property Management's full inventory.

This is a **reusable** import. Run it once per onboarding (per Hostfully agency). It imports every property's structured data + photos + amenities + channel listing IDs into PropDesk so the company can move off Hostfully cleanly. The Airbnb listing ID is the SEO-critical piece — preserving it means NextPax (or any future channel manager) can adopt the existing Airbnb listing, keeping the reviews and search ranking instead of starting fresh.

---

## 1. What this does

For every property in the Hostfully agency, the importer writes to PropDesk:

| Target | Field(s) | Behavior |
|---|---|---|
| `properties` | `hostfully_uid`, `bedrooms`, `bathrooms`, `max_guests`, `latitude`, `longitude`, `wifi_name`, `wifi_password`, `hostfully_listing_url`, `min_stay`, `is_active` | **Overwrite** (Hostfully is truth for these factual fields) |
| `properties` | `address`, `apt`, `city`, `state`, `zip` | **Skip** (Hostfully data is dirty — different cities/zips for the same building. PropDesk is truth.) |
| `property_settings.descriptions` | summary/headline/etc. | **Seed only** if PD has nothing — never overwrite (PD's AI generator owns these) |
| `property_settings.main_settings` | property type, listing type, beds, sqft, floor | **Overwrite** |
| `property_settings.pricing` | base, weekend, tax, deposit, cleaning, cleaning_tax, currency | **Overwrite** |
| `property_settings.fees_policies` | turnover days, cancel policy, deposit %, full-payment timing | **Overwrite** |
| `property_settings.app_content` | check-in / check-out times, guidebook URL | **Overwrite** |
| `property_settings.amenities` | snake_case map of every amenity (~80 codes) | **Overwrite** but preserves `washer_type` / `dryer_type` (in_unit / shared distinction PropDesk tracks beyond Hostfully) |
| `property_settings.photos` | full photo array | **Replace** (still using HF CDN URLs; Phase 3 will mirror to Supabase Storage) |
| `property_channels` | hostfully + airbnb (real IDs), vrbo + booking_com + marriott (placeholder `pending-<uid_prefix>`) | **Replace** (delete then insert per property) |

**Why placeholders for VRBO / Booking / Marriott?** Hostfully's main `/properties/{uid}` payload exposes the **Airbnb listing ID** but NOT the others — they're hidden behind separate endpoints we haven't reverse-engineered yet. The placeholders mean PropDesk knows the channel is connected; the real ID has to be filled in manually before NextPax handoff for those channels.

---

## 2. Prerequisites

### 2.1 Hostfully API key

Get the company's Hostfully **production** API key from their Hostfully dashboard (Account → Integrations → API). Keep it private — it's the equivalent of a service-account password for their entire inventory.

### 2.2 PropDesk Supabase access

You need:
- Supabase URL + service-role key in `portal/api/config.php` (this is the PropDesk install you're importing into)
- The `ADMIN_TOKEN` constant in `portal/config.php` (used to gate the import page)

### 2.3 Migrations applied

These two migrations must be run in the Supabase SQL Editor **before** running the import:

```sql
-- migrations/2026-05-06-properties-is-active.sql
alter table properties add column if not exists is_active boolean default true;
update properties set is_active = true where is_active is null;
```

```sql
-- migrations/2026-05-06-property-channels.sql
-- (full content in migrations/ — creates property_channels with property_uid integer)
```

**Critical:** `property_channels.property_uid` must be `integer`, not `uuid`. If you copy an older draft of this migration that declares it as uuid, the inserts will silently fail (Postgres rejects integer values into a uuid column but the service-role helper swallows the error).

Verify after running:
```sql
select column_name, data_type
from information_schema.columns
where table_name = 'property_channels' and column_name = 'property_uid';
-- Expected: property_uid | integer
```

### 2.4 Hostfully credential row in PropDesk

Insert the API key into `app_credentials` (or use the **Setup Hostfully** button in Settings → Credentials — see §6):

```sql
insert into app_credentials (service, label, credentials, active)
values (
  'hostfully',
  'Hostfully API (production)',
  '{"env":"production","api_key":"PASTE_KEY_HERE"}'::jsonb,
  true
)
on conflict (service) do update
set credentials = excluded.credentials, active = true;
```

The PHP importer reads it via `getCredential('hostfully')`. Sandbox mode is supported (set `env: "sandbox"` and the importer hits `https://sandbox.hostfully.com/api/v3` instead).

### 2.5 Files in place on the PropDesk web host

- `portal/api/import-hostfully.php` — the importer (admin-only, ~1500 lines)
- `portal/api/config.php` — Supabase + ADMIN_TOKEN constants
- `portal/api/index.php` — for the `getCredential()` helper

The importer doesn't touch `index.php` — it's a standalone admin tool.

---

## 3. Run the import

### 3.1 Open the audit page

```
https://<your-propdesk-domain>/api/import-hostfully.php?admin_token=<TOKEN>&action=audit
```

You'll see a side-by-side comparison:
- Every Hostfully property + every PropDesk property cross-referenced by **apt code** (PropDesk's `apt` field matched to Hostfully's `address.address2`)
- Status icons: ✅ Linked · ❌ Missing in PD · 👻 Orphan in PD
- 🟢 / ⚫ Hostfully `isActive` flag

If the audit shows fewer Hostfully properties than expected, it's almost always pagination — the importer pages through `_limit=100&_offset=…` until it gets a short page. Hit `?action=debug_agencies` to see the raw response.

### 3.2 Bulk transfer (recommended)

At the top of the audit page is a **🚀 Bulk Transfer** banner. Click it. A confirmation dialog appears. Confirm.

You'll see a live progress bar as it sequentially fires `save_mapping` for each matched property:

- 🟢 `✓ <apt>: hostfully, airbnb, vrbo, booking, marriott, properties, descriptions, settings, pricing, fees, app_content, amenities, photos`
- 🟠 `⚠ <apt> partial: …` — channel rows wrote but some property field failed
- 🔴 `✗ <apt> failed: <reason>` — full failure for that property

Roughly **5 sec per property**. 30 properties = ~2.5 min total.

When it finishes, the banner shows totals (`<X> transferred, <Y> partial, <Z> failed`) and a "Reload audit" link.

### 3.3 Single property (manual / debugging)

In the audit table, every clean-match row has a **🔗 Link** button. Clicking it fires the same `save_mapping` action for just that property and redirects with a green banner showing what was written.

Useful when:
- Testing the import on one property before bulk-running
- Re-importing a single property after fixing data in Hostfully
- A single property failed in the bulk run and you want to retry it

### 3.4 Re-running

The import is **idempotent** for everything that matters:
- `property_channels` rows are deleted + re-inserted per property
- `property_settings` JSONB blocks are upserted (overwrite policy)
- `properties.hostfully_uid` is PATCHed to the same value

Re-running is safe. The only thing that won't restore is if you've **manually edited** a description / address in PD after the import — those fields are protected so re-runs won't undo your edits.

---

## 4. Post-import work

### 4.1 Generate clean public names + descriptions

PropDesk seeds `property_settings.descriptions.name` from Hostfully only if PD has nothing. Use the **AI Description Generator** in Property Settings to write clean SEO-friendly names + descriptions. The generator has bulk mode + sibling-dedup logic to avoid identical-sounding listings.

### 4.2 Fill in VRBO + Booking.com + Marriott listing IDs

Run this query to find the placeholders:
```sql
select property_uid, channel, external_id
from property_channels
where external_id like 'pending-%'
order by property_uid, channel;
```

For each row, log into Hostfully → Channels → click the channel → copy the listing ID → update:
```sql
update property_channels
set external_id = '<real-id>', updated_at = now()
where property_uid = <PD_uid> and channel = 'vrbo';
```

Until these are filled in, NextPax can't claim the existing VRBO / Booking listings — it'd create new ones, losing the reviews. **This is a blocker for the SEO-preserving handoff** for those three channels (Airbnb is fine because its real ID was imported).

### 4.3 (Optional) Mirror photos to Supabase Storage

The importer writes Hostfully's CDN URLs (`orbirental-images.s3.amazonaws.com/...`) directly. They work indefinitely as long as Hostfully keeps serving them, but for full independence you'll want to:

1. Loop every photo URL in `property_settings.photos`
2. Download it
3. Upload to Supabase Storage bucket `property-photos` under `<property_uid>/<sortOrder>.jpg`
4. Replace the URL in `property_settings.photos` with the new public URL

Not built yet. Phase 3.

---

## 5. Field mapping reference

The full Hostfully → PropDesk field mapping (every field, every decision) lives at:

```
migrations/2026-05-06-hostfully-field-mapping.md
```

Read that doc when you need to know exactly what happens to each Hostfully field. It documents the IMPORT / SKIP / OVERWRITE decision per field with the reasoning.

---

## 6. Settings → Import tab (admin SPA)

A convenience tab lives at **Settings → Import** in the admin. It:

1. Lets you save the Hostfully API key into `app_credentials` (instead of running raw SQL)
2. Has an "Open Hostfully Import" button that launches `import-hostfully.php?action=audit` in a new tab with the admin token pre-filled

This is the easiest path for non-technical onboarding. The button is just a launcher — all the actual work happens in the PHP page on the server.

If you're onboarding a new PropDesk install, the Settings tab automatically appears once `js/app.js` is deployed with the import-tab code.

---

## 7. Troubleshooting

### 7.1 "Hostfully API key not found in app_credentials"

The credential row is missing or `active=false`. Re-run the SQL in §2.4 or use the Settings → Credentials → Setup Hostfully button.

### 7.2 Audit shows 20 properties but the company has 33

Pagination. The importer hits `_limit=100&_offset=0,100,200,…` until it gets a short page. Older versions had `_limit=20` hardcoded. Check the version of `import-hostfully.php` — `hf_list_all_properties()` should loop offsets.

### 7.3 Bulk Transfer succeeds but no rows in `property_channels`

Type mismatch on `property_channels.property_uid` (uuid instead of integer). Run:
```sql
select column_name, data_type
from information_schema.columns
where table_name = 'property_channels' and column_name = 'property_uid';
```
If `data_type` is `uuid`, run:
```sql
alter table property_channels alter column property_uid type integer using property_uid::text::integer;
```
(This will fail loudly if there's any non-integer data — clean it up first.)

### 7.4 "Unauthorized" from the import URL

`admin_token` query param doesn't match `ADMIN_TOKEN` in `portal/config.php`. Check the token value. The default for Willow's instance is `willow2026!` — change for new tenants.

### 7.5 Some properties show as ❌ Missing in PropDesk

Hostfully has them but PropDesk doesn't. Two options:
- Create a stub PropDesk property first (apt code matching Hostfully's `address.address2`), then re-run the audit and bulk-link
- Skip them — they won't get imported

### 7.6 Some properties show as 👻 Orphan in PropDesk

PropDesk has `hostfully_uid` set to a UID Hostfully doesn't return. Probably a deleted/archived Hostfully property. Safe to clear:
```sql
update properties set hostfully_uid = null
where hostfully_uid in (
  -- list of orphan UIDs from the audit
);
```

### 7.7 Photos missing on a property after import

Hostfully's `/photos?propertyUid=X` endpoint can return empty for newly-created properties. Check directly:
```
?admin_token=<TOKEN>&action=debug_photos&hf_uid=<UID>
```

### 7.8 Amenities import says all blank

Hostfully's amenities are returned as canonical codes (`HAS_FIRE_EXTINGUISHER`, etc.). The importer maps them to PropDesk's snake_case keys via `hf_amenity_to_pd_key()` with ~80 special cases. If a code isn't in the map, it falls through to lowercased + stripped form. To see what was returned vs. mapped:
```
?admin_token=<TOKEN>&action=debug_amenities&hf_uid=<UID>
```

---

## 8. Onboarding a NEW company (full sequence)

For each new PropDesk customer who is currently on Hostfully:

1. Stand up their PropDesk Supabase project + admin SPA + portal (separate from Willow's)
2. Run all `migrations/*.sql` in date order
3. Get their Hostfully API key
4. Settings → Credentials → Setup Hostfully → paste API key
5. Settings → Import → click "Run Audit" → review the audit report
6. If everything matches, click 🚀 Bulk Transfer
7. Wait ~5 sec/property for the loop to complete
8. Manually fill in VRBO / Booking.com / Marriott listing IDs (§4.2)
9. Generate clean public names + descriptions via AI generator
10. Verify a sample property's Property Settings page in admin SPA — should show all fields populated, photos visible, amenities checked off

That's the onboarding playbook. Repeat per customer.

---

## 9. What's NOT imported

- Reservations / bookings (live/historical) — out of scope; new bookings come via NextPax going forward
- Guest contact records — the company can export from Hostfully separately if they want them
- Owner / agent assignments — Hostfully-internal, irrelevant once they leave
- Pricing rules (seasonal, length-of-stay) — out of scope; managed in PropDesk's pricing module
- Reviews — preserved automatically because we keep the Airbnb listing ID; never copied raw

---

## 10. Files involved

| File | Role |
|---|---|
| `portal/api/import-hostfully.php` | The importer — audit + save_mapping + debug actions |
| `portal/api/config.php` | Supabase URL + service-role key + ADMIN_TOKEN |
| `portal/api/index.php` | Provides `getCredential()` helper |
| `migrations/2026-05-06-properties-is-active.sql` | Adds `properties.is_active` |
| `migrations/2026-05-06-property-channels.sql` | Creates `property_channels` registry |
| `migrations/2026-05-06-hostfully-field-mapping.md` | Field-by-field decision reference |
| `index.html` (admin SPA) | Settings → Import tab |
| `js/app.js` (admin SPA) | Settings → Import tab handlers |

Keep all of these in version control. The PHP importer + the two migrations are the minimum viable set for a new tenant.
