Product specification · Ubio Ltd

Hotel Universe.
The complete user story backlog

Every capability of the hotel data and rate-intelligence platform — what is built, what is half-built, and what comes next. Derived from the live product, the hotel-universe-api monorepo and the hotel-universe-portal repository.

742
user stories
33
epics
221
built
134
partial
387
proposed
8 August 2026 · 11 parts · 3 appendices
742 shown
Executive summary

The platform today, and where it goes next

What already exists

Hotel Universe is, at its core, a canonical hotel database with a matching engine bolted to the front of it. Records are ingested from Google Hotels, Google Places, Booking.com, TripAdvisor and Expedia, validated against schemas, and merged into a single canonical record per property in canonicalData6 — around a million of them. The merge is field-group by field-group with a fixed source priority, and every group records which source it came from, so the record carries its own provenance. Each property gets a ten-character huId, and a resolution ladder walks reference IDs across sources to decide when two source records are the same hotel. Search sits on a MongoDB Atlas Search index with geospatial, text, star, country, city, amenity and data-availability filters, cursor-paginated and served through a REST API of roughly fifty endpoints. A scraping service keeps the sources refreshed on a thirty-day staleness threshold, running batches of a hundred at fifty concurrent requests.

On top of that sits the workflow the product actually sells: bring your own list. A customer uploads a CSV and it is matched either exactly, by any of a long list of reference ID types — Google, Booking, TripAdvisor, Expedia, Agoda, Trivago and around thirty more — or by similarity on names, addresses and coordinates when no IDs exist. Confident rows land in the list; uncertain ones go to a review queue where a human sees the uploaded row beside the candidate property with per-signal confidence on name, address, postcode and geography, and approves or rejects. Lists can also be built from a saved search, from a selection, or by combining other lists, and a saved search stays dynamic — it can be refreshed, diffed against what it would return today, and converted to a static list. Exports stream out as gzipped JSON to Google Cloud Storage behind fifteen-minute signed URLs, with a thirty-day file lifetime.

The two pricing products layer on from there. Metasearch proxies live OTA offers for a property on given dates and guest configuration, with rate type, currency, locale and device controls, returning per-advertiser offers with room detail, cancellation dates and per-night breakdowns. Around it the portal has grown a genuine analytics surface: price insights with trend, volatility, weekend premium and a raise/soften/hold recommendation; a competitor comp-set builder with weighted criteria across stars, rating, property type, amenities and distance; a date-by-hotel price matrix with market medians; and market insights across a whole list. Direct ARI pulls the hotel's own rates and availability from its booking engine — per room, per rate plan, with board basis and cancellation terms, up to a year ahead — with a refresh flow that batches and polls, and coverage reporting per list. Linking a property to its booking engine is currently an operation Ubio staff perform through internal screens.

Wrapped around all of it is a commercial and AI layer. Authentication is Google OAuth only; organisations are flat lists of members with no roles, and multi-tenancy is enforced by scoping essentially every read and write to an organisation ID from the token. Personal access tokens carry a narrower scope set for API and MCP use. Entitlement is driven by tier plus quota, with usage counted from a time-series access log covering hotel views, review views, metasearch queries, list creation and every export create and download; Stripe handles subscriptions across three products, and the live portal exposes per-product plans, proration, cancellation and reactivation. The AI assistant is no longer a side feature — it is the default search mode, streaming, with conversation history, a context meter, typed artifacts for search results, hotels, images, lists and pricing, and one-click actions that turn a conversation into a saved list. The same primitives are published as six MCP tools for Claude, ChatGPT, Cursor or a customer's own agent.

What is proposed, and why

The largest block of proposed work is not new features — it is closing the distance between what the platform promises and what it enforces. Three endpoints ship with no authentication at all, the rate limiter guards exactly one route, invitations are gated by the wrong permission, there are no per-user roles, and the internal super-user check is a substring match on an email address. The landing page sells CSV export and webhook diffs; neither exists. A three-hundred-and-sixty-five-day subscription has no expiry warning, no renewal path and no freshness guarantee behind it, while the scraper refreshes on a flat thirty-day cadence that does not know which hotels a customer is paying for. The API's own OpenAPI document is six versions stale and documents an endpoint that was never built. None of these are hard problems; all of them are the sort of thing a serious customer's procurement review finds in an afternoon. The proposed stories name each one, attach acceptance criteria to it, and Appendix A collects them in a single table.

Beyond that, the proposed work pushes in three directions. Make the data defensible: a source-agnostic canonical ID so identity no longer depends on Google, machine-learned matching with a measurable precision and recall against a labelled set, a merge-and-split tool for properties that were wrongly conflated, per-field trust scores instead of a fixed source ranking, and data-quality dashboards that show completeness, freshness and conflict rates rather than asking customers to discover them. Make the data reachable: outbound webhooks and a change feed keyed on huId, warehouse and bucket delivery, scheduled and incremental exports, and a remote MCP server with OAuth, per-tool scopes and spend caps so an agent can be trusted with a key. Make the commercial motion self-serve: real pricing visible before sign-up, sign-in that does not require a Google account, a sandbox with fixture data, usage-based metering reported to Stripe, quota warnings before the wall rather than a 402 at it, and self-serve booking-engine linking so Direct ARI stops being a managed service. Taken together the proposed stories are less a wish list than a description of the product this one is already trying to be.

What this document is

A single backlog covering every user-facing and platform-facing capability of Hotel Universe: what exists today, what is half-built, and what a platform of this shape plausibly needs next. It is written to be read by a product manager, sliced into a delivery plan, or imported into a tracker.

Each story carries a status, a persona, a priority, acceptance criteria in Given/When/Then form, implementation notes that name the real endpoint, component or collection involved, and a note on edge cases and risks.

What Hotel Universe does

The product's own framing on the landing page is “Bring your list. See every rate.” A customer arrives with a file of hotels — a portfolio, a comp set, a supply file — and the platform:

  1. 1

    Matches

    That file against canonical hotel records — exactly by reference ID (Booking, Expedia, Google Hotels, TripAdvisor, Trivago, Agoda, or a Hotel Universe ID) or by AI similarity on names and addresses, with confident rows landing straight in the list and uncertain rows going to a human review queue.

  2. 2

    Enriches

    The matched properties with canonical content — names, addresses, photos, room types, amenities, reviews — deduplicated across sources so the customer's catalogue stops disagreeing with itself.

  3. 3

    Keeps it fresh

    For 365 days by subscription.

  4. 4

    Prices it

    Two ways: OTA metasearch comparison across advertisers, and direct ARI pulled from the hotel's own website and booking engine.

  5. 5

    Delivers it

    Through the portal, a REST API, and an MCP server that exposes the same primitives to Claude, ChatGPT, Cursor or a customer's own agent.

The four stated audiences are OTAs and aggregators, hotel owners and revenue managers, travel agencies and corporate buyers, and developers building AI agents.

A note on evidence

Statuses reflect what could be verified from the two repositories and the live bundle. The local hotel-universe-api clone is from March 2026 and is behind production — the live portal calls a substantial number of endpoints that are not in that clone (AI assistant, direct pricing, price insights, competitors, geocoding, list combine and diff, per-product billing). Where that is the case the story is marked Built or Partial on the evidence of the portal's API client, and the implementation note says so.

How to read a story

Built

Works today in the portal or the API.

221 stories
Partial

Exists but with a named gap — one tier only, no UI, a TODO in code, stale docs, or a missing auth check.

134 stories
Proposed

Does not exist. Grounded in the platform's actual architecture, not generic SaaS filler.

387 stories

Priority. Must — the product does not work without it. Should — significant value, not blocking. Could — worthwhile when capacity allows. Won't-yet — deliberately deferred, recorded so the decision is visible.

Personas

Fourteen external personas and six internal ones. The number beside each is how many stories it owns.

OTA product manager51

Owns the hotel surface at an online travel agency

Coverage, correctness, time to ship

OTA supply manager45

Manages the supply file and property mapping

Match rate, duplicates, freshness

Customer data engineer57

Integrates Hotel Universe into a pipeline

Stable IDs, bulk delivery, schema contracts

Hotel owner36

Runs one or a few properties

Where they sit against competitors, direct vs OTA

Revenue manager60

Sets rates for a portfolio

Comp sets, undercutting, pace and parity

Travel agency consultant34

Builds client programmes

Policy-compliant shortlists, exportable

Corporate travel buyer39

Negotiates and audits hotel programmes

Coverage near offices, rates, compliance

Developer39

Builds against the REST API

Auth, docs, errors, rate limits, SDKs

AI agent builder19

Wires the MCP server into an agent

Scoped keys, tool schemas, streaming, quotas

AI agent7

A non-human consumer of the tools

Deterministic errors, pagination, idempotency

Organisation admin73

Owns the account

Members, permissions, billing, usage

Data analyst53

Analyses the output

Exports, warehouse delivery, provenance

Trial user27

Evaluating the product

Getting to a first useful result fast

Prospect26

Not signed up yet

Understanding fit and price before committing

Internal (Ubio)

Ubio platform engineer60
Ubio data ops engineer52
Ubio support agent21
Ubio sales rep13
Ubio finance ops20
Ubio admin10

Epic index

33 epics across 11 parts. Each bar shows the built / partial / proposed split.

Part 1 — Account, access & navigation

Part 2 — Finding hotels

Part 3 — Hotel content & data quality

Part 4 — AI, agents & the developer platform

Part 5 — Lists, matching & the review queue

Part 6 — Getting data out

Part 7 — Rates & price intelligence

Part 8 — Subscriptions, billing & usage

Part 9 — Onboarding, growth & internal tooling

Part 10 — The data platform

Part 11 — Security, performance & quality

Part 1

Account, access & navigation

AUTH 8 built2 partial12 proposed

Authentication, identity & session

Everything a person or machine does in Hotel Universe hangs off a bearer credential: a Google-issued JWT for humans in the portal, or a pat_ access token for API and MCP clients. Today the only human sign-in path is Google OAuth, first login silently creates both a User and a "<name>'s Organization" at tier TRIAL, and the portal keeps the session alive with a five-minute POST /auth/refresh loop against a token held in localStorage under ubio_auth_token. This epic covers what exists, the gaps around expiry and credential hygiene, and the enterprise identity surface (SSO, SCIM, MFA, session revocation, service accounts) that OTAs and corporate buyers ask for before they will sign.

AUTH-01

Sign in with Google

Built
ProspectMust
As a Prospect, I want to sign in to the portal with my existing Google account, so that I can evaluate Hotel Universe without creating yet another password.
Acceptance criteria
  1. Given I am on /landing or /login, when I click "Sign in to portal" or "Continue with Google", then I am sent to Google's consent screen with scope email profile and redirect ${FRONTEND_URL}/googleAuthCallback.
  2. Given I complete consent, when Google returns a code, then the portal exchanges it via POST /auth/google and receives {user, token}.
  3. Given the exchange succeeds, when the token is stored under ubio_auth_token, then router.beforeEach allows me onto / which redirects to HotelsSearch.
  4. Given I cancel at Google or the exchange fails, when I land back on /googleAuthCallback, then the error state renders the parsed Authentication failed: {json} message and offers a retry, and no token is written.
  5. Given I am already authenticated, when I navigate to Login or Landing, then I am redirected to Home.
Implementation notes

app/layout/AppLogin.vue and components/landing/useSignIn.ts both call the scaffold AuthService.login() with authProvider: 'google' from Config; the API side exchanges the code with GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET and fetches userinfo before minting the JWT.

Edge cases & risks

Google is a single point of failure for all human access — an OAuth outage locks out every customer with no fallback credential. Corporate tenants that block third-party Google apps cannot sign in at all, which is a live sales blocker.

AUTH-02

Handle the OAuth callback cleanly

Built
Trial userMust
As a Trial user, I want the callback screen to tell me what is happening while my sign-in completes, so that I am not staring at a blank page or a URL full of OAuth debris.
Acceptance criteria
  1. Given Google redirects to /googleAuthCallback?code=…&state=…, when the component mounts, then it shows the processing state and calls auth.handleGoogleCallback(code, state).
  2. Given the callback succeeds, when the token is persisted, then the success state renders briefly and navigation proceeds.
  3. Given the callback returns error in the query, when the component mounts, then the error state renders instead of a spinner that never resolves.
  4. Given the provider appends hash fragments (state=, session_state=, code=), when router.beforeResolve runs, then the fragments are stripped and the route is replaced so the address bar is clean and the URL is not shareable with a live code.
Implementation notes

app/layout/GoogleAuthCallback.vue takes code, error and state as route props; fragment stripping lives in RouterManager.ts's beforeResolve guard.

Edge cases & risks

A code replayed from browser history or a copied URL will fail exchange, but only after a round trip — the resulting error copy is generic. There is no CSRF check tying state to a value the portal generated and stored.

AUTH-03

Return to the page I was trying to reach

Built
Data analystShould
As a Data analyst, I want a deep link I opened while signed out to survive the sign-in detour, so that a shared link to a list or hotel profile actually lands me on that screen.
Acceptance criteria
  1. Given I open /lists/abc123 while unauthenticated, when beforeEach blocks the navigation, then the intended URL is written to sessionStorage as redirectUrl and I am sent to Landing.
  2. Given I then sign in successfully, when the callback completes, then goToRedirectUrl() restores /lists/abc123 and clears the stored value.
  3. Given the stored redirectUrl points at a different origin, when goToRedirectUrl() runs, then the same-origin check rejects it and I land on Home instead.
  4. Given no redirectUrl was stored, when sign-in completes, then I land on Home (/search).
Implementation notes

RouterManager.setRedirectUrl() / goToRedirectUrl(clear = true); /login also accepts redirectUrl as an explicit query prop.

Edge cases & risks

sessionStorage is per-tab, so a sign-in completed in a different tab loses the redirect. The stored URL can point at a route the user's tier cannot reach (e.g. requiresMetasearchAccess), bouncing them to Billing immediately after login.

AUTH-04

Keep my session alive while I work

Built
Revenue managerMust
As a Revenue manager, I want my session refreshed in the background, so that a long afternoon of price comparison is not interrupted by a surprise sign-out.
Acceptance criteria
  1. Given the app boots with a token, when App's constructor runs, then apiManager.startTokenRefreshInterval() schedules a refresh every 5 minutes.
  2. Given the interval fires, when POST /auth/refresh returns {token}, then ubio_auth_token is replaced and ubio_last_token_refresh is stamped.
  3. Given a request is about to be sent, when ensureValidToken() finds the token stale, then a refresh is performed before the call proceeds.
  4. Given POST /auth/refresh returns 401, when the interval fires, then the interval stops and the user is routed to sign-in rather than looping failed refreshes.
Implementation notes

ApiManager.refreshToken(), ensureValidToken(), startTokenRefreshInterval() / stopTokenRefreshInterval(); the JWT itself is long-lived (JWT_TOKEN_EXPIRATION_SECONDS e.g. 604800 = 7 days).

Edge cases & risks

Several tabs each run their own 5-minute interval and race to write ubio_auth_token; a tab suspended by the browser can wake with a token refreshed elsewhere. Criterion 4 is the weakest part of the current behaviour — see AUTH-10.

AUTH-05

Sign out

Built
Organisation adminMust
As an Organisation admin, I want a clear sign-out action, so that I can leave a shared or borrowed machine without leaving my org's data open.
Acceptance criteria
  1. Given I open the profile menu, when I click "Logout", then POST /auth/logout is called and ubio_auth_token plus ubio_last_token_refresh are removed.
  2. Given logout completes, when the app re-renders, then the refresh interval is stopped and I am on Landing.
  3. Given POST /auth/logout fails or times out, when I click Logout, then local credentials are still cleared and I am still signed out of the portal.
  4. Given I press Back after logout, when the router resolves a requiresAuth route, then I am sent to Landing, not to a cached authenticated screen.
Implementation notes

ProfileMenu.vueApiManager.logout(); AccessManager.clear() drops cached access info.

Edge cases & risks

Client-side state outlives logout: hotel-universe:selected-hotels, hotel-universe:ai-assistant-chat and directPricingActiveBatches are not purged, so a second user on the same browser can see the previous user's selection and assistant transcript. That is a genuine privacy defect worth fixing alongside AUTH-16.

AUTH-06

Create my account on first sign-in

Built
ProspectMust
As a Prospect, I want my account to be created automatically the first time I sign in, so that there is no separate registration form to fill in.
Acceptance criteria
  1. Given no users record matches my Google identity, when POST /auth/google succeeds, then a User is created with a 10-character id plus googleId, email, name, avatarUrl and timestamps.
  2. Given invitations exist for my email address, when the user record is created, then those pending invitations are processed and I become a member of the inviting organisations.
  3. Given I sign in again later, when POST /auth/google succeeds, then the existing user is reused and lastOrganizationId drives which org I land in.
  4. Given the same email arrives from a different Google identity, when the lookup runs, then it must not silently create a duplicate user record for the same address.
Implementation notes

First-login handling in the auth service: create User, process pending invitations by email, then fall through to org creation (AUTH-07). GET /users/me returns {id, email, name, avatarUrl, lastOrganizationId} and, on first sign-up, the initialSignUp flag that triggers the onboarding tour.

Edge cases & risks

Invitation processing at first login is the only place an invite is auto-consumed; if it throws after the user is created, the user exists in a fresh trial org while the invite still shows as pending to the admin who sent it.

AUTH-07

Get a trial organisation without asking

Built
Trial userMust
As a Trial user, I want an organisation created for me on first sign-in, so that I can search hotels immediately instead of configuring a workspace.
Acceptance criteria
  1. Given I am a brand-new user with no org membership, when first login completes, then an organisation named "<name>'s Organization" is created with tier: TRIAL and me as the sole member.
  2. Given the org is created, when I reach /search, then trial gating applies: hotel profiles are capped at ACCESS_TIER_TRIAL_LIMIT (default 100 distinct hotels), and reviews and metasearch are unavailable.
  3. Given I joined an existing org via a pending invitation, when first login completes, then no personal trial org is created and I land in the inviting org.
  4. Given the trial hotel limit is reached, when I open another profile, then GET /hotels/{huId} returns 402 with "Upgrade to view. Contact sales at hello@ubio.ai" and the portal renders UpgradeRequired.
Implementation notes

Org auto-creation sits in the Google auth flow; the tier ceiling is enforced by AccessTierService counting distinct huId values in the time-series accessLogs collection for action hotels:view.

Edge cases & risks

The org name is derived from the Google profile name, so shared or generic Google names produce confusing org names that only the admin can fix in Settings. The trial hotel count is all-time and never resets, so a long-running evaluation quietly dead-ends.

AUTH-08

Issue a machine credential for the API and MCP server

Partial
DeveloperMust
As a Developer, I want to mint a personal access token in the portal, so that my scripts, my MCP client and my agents can call the API without a browser session.
Acceptance criteria
  1. Given I open /tokens, when I click Generate new token and supply a name, then POST /tokens returns {secret, accessToken} and the dialog shows the pat_… secret once with a copy button and the warning "Make sure to copy the token now…".
  2. Given I have a PAT, when I call the API with Authorization: Bearer pat_…, then the SHA-256 hash resolves an accessTokens record and the principal is a serviceAccount with displayName: "PAT <name>" and the org and scopes from that record.
  3. Given I delete a token from the row context menu, when I confirm, then DELETE /tokens/{id} revokes it and subsequent calls with that secret return 401.
  4. Given I try to manage tokens using a PAT, when the request is authorised, then it is refused because PAT scopes exclude pat:manage and orgs:manage.
  5. Given the token list renders, when I look at the "Expiration Time" column, then it is empty, because PATs never expire — the gap this story records.
Implementation notes

AccessTokens.vue + ApiManager.getPersonalAccessTokens/createPersonalAccessToken/deletePersonalAccessToken; server-side accessTokens {_id, name, hash, scopes[], createdAt, createdBy, orgId}. The portal also emits access_token_created / token_deleted telemetry.

Edge cases & risks

Non-expiring secrets with no last-used timestamp and no rotation story are the single largest credential risk on the platform; a leaked pat_ in a public repo grants org-wide read of hotels, reviews, metasearch, lists and exports indefinitely. docs/docs.http still documents a non-existent POST /auth/pat, so integrators can start from the wrong endpoint.

AUTH-09

Restore my organisation context on return

Built
Organisation adminShould
As an Organisation admin who belongs to several organisations, I want the portal to reopen in the org I last used, so that I do not accidentally act on the wrong tenant's data.
Acceptance criteria
  1. Given lastOrganizationId is set on my user, when I sign in, then the minted JWT is scoped to that organisation.
  2. Given I switch org during a session, when I sign in again later, then the switched-to org is the one restored.
  3. Given my membership of lastOrganizationId has been revoked, when I sign in, then I am placed in another org I do belong to rather than receiving a token for an org I cannot access.
  4. Given I belong to no organisation at all, when I sign in, then a trial organisation is created per AUTH-07 instead of a blank shell.
Implementation notes

users.lastOrganizationId is returned by GET /users/me and updated by POST /auth/switch-org; the portal reads the membership list via GET /users/me/orgs.

Edge cases & risks

Criterion 3 is the fragile one — removal from an org (ORG-07) does not clear other users' lastOrganizationId, so the next sign-in can mint a token for a membership that no longer exists.

AUTH-10

Fail gracefully when my session expires

Partial
Data analystMust
As a Data analyst, I want an expired session to end with a clear prompt and my place preserved, so that I do not lose a long-running export or a half-built filter set to a wall of failed requests.
Acceptance criteria
  1. Given my JWT has expired, when any API call returns 401, then the portal clears credentials once, stores the current route as redirectUrl and routes me to sign-in with an explanatory message.
  2. Given a background refresh fails while I am idle, when I next interact, then I see the expiry prompt rather than a silent series of empty tables.
  3. Given I re-authenticate from the prompt, when sign-in completes, then I return to the exact route and query string I was on.
  4. Given unsaved work exists (a selection, an assistant draft), when the session expires, then that state is preserved through re-authentication rather than discarded.
  5. Given access.refreshAccessInfo() throws inside beforeEach, when navigation continues, then the resulting tier gating must not silently mis-render a paid screen as trial.
Implementation notes

The gap is concrete: router.beforeEach awaits access.refreshAccessInfo() and swallows failure, continuing navigation, and there is no central 401 interceptor in ApiManager (3782 lines) — each caller surfaces its own error state or toast.

Edge cases & risks

With a 7-day JWT and a 5-minute refresh loop, expiry mostly bites laptops resumed from sleep — exactly when a user has many tabs open and will hit the failure ten times before understanding it. Swallowed access-info failures can also mask a tier downgrade, showing metasearch UI that then 402s.

AUTH-11

Sign in with email and password

Proposed
Corporate travel buyerShould
As a Corporate travel buyer at a company that does not use Google Workspace, I want to register and sign in with an email address and password, so that I can buy and use the product at all.
Acceptance criteria
  1. Given I have no Google account, when I register with email and password, then a users record is created without googleId and an email-verification link is sent before the account can sign in.
  2. Given I sign in with correct credentials, when authentication succeeds, then I receive the same JWT and scope set as a Google user, and org auto-creation behaves exactly as in AUTH-07.
  3. Given I enter a wrong password, when I submit, then the response is a generic failure that does not reveal whether the address exists, and repeated attempts are rate-limited per address and per IP.
  4. Given I forget my password, when I request a reset, then a single-use, time-limited link is emailed and consuming it invalidates all other active sessions for that user.
  5. Given an email already registered via Google, when I attempt password registration, then I am told to continue with Google rather than silently creating a second identity.
Implementation notes

Adds POST /auth/register, POST /auth/login, POST /auth/password/reset alongside POST /auth/google, with Argon2id hashes on the users record. Email delivery would extend the SendGrid service, which today sends exactly one message type (sendInvitationEmail).

Edge cases & risks

Password auth drags in verification, reset, lockout and breach-list checks — a real ongoing cost. Account-linking between a Google identity and a password identity on the same address is the classic takeover vector and must be verified-email-gated.

AUTH-12

Sign in with a magic link

Proposed
Travel agency consultantCould
As a Travel agency consultant, I want to sign in from a one-time link sent to my work address, so that I can get into a shared client workspace without managing another password.
Acceptance criteria
  1. Given I enter my email on the sign-in screen, when I request a link, then a single-use token valid for 15 minutes is emailed and the screen shows a neutral "check your inbox" message regardless of whether the address exists.
  2. Given I open the link, when the token validates, then a session is created with the same scopes as a Google login.
  3. Given the link has already been used or has expired, when I open it, then I see an expired-link screen with a one-click resend.
  4. Given the link is opened on a different device from the one that requested it, when the token validates, then the sign-in is allowed only if the request-device fingerprint matches or I confirm a code shown on the requesting device.
Implementation notes

Reuses the invitation-token pattern already in the codebase (/invite/accept?token=), but with proper single-use consumption and short expiry rather than the invitation's 7-day, id-as-token model.

Edge cases & risks

Email forwarding and corporate link-scanners pre-fetch URLs and can burn single-use tokens before the human clicks; a POST-on-confirm interstitial avoids this.

AUTH-13

Sign in through my company's SSO

Proposed
Corporate travel buyerShould
As a Corporate travel buyer, I want Hotel Universe to federate with our SAML or OIDC identity provider, so that access follows our joiner-mover-leaver process and passes security review.
Acceptance criteria
  1. Given my organisation has SSO configured with a verified email domain, when I enter my work address on the sign-in screen, then I am redirected to our IdP instead of Google.
  2. Given the IdP returns a valid assertion, when the callback is processed, then I am signed in as a member of the mapped organisation and no personal trial org is created.
  3. Given my organisation enforces SSO-only, when I attempt Google sign-in with a domain-matching address, then the attempt is refused with a message pointing me at SSO.
  4. Given I am deprovisioned at the IdP, when my next session refresh runs, then POST /auth/refresh fails and I lose access without an admin touching the members list.
  5. Given the assertion is unsigned, replayed or from an unconfigured issuer, when it is validated, then the sign-in is rejected and the attempt is recorded in the login audit trail (AUTH-17).
Implementation notes

Builds on the existing JWT minting path (JWT_ISSUER, JWT_PRIVATE_KEY) — federation only changes how identity is established. Per-org IdP config would live on organizations alongside tier and billing. Note that app/public/silent-check-sso.html and AC_PUBLIC_ENV_KEYCLOAK_URL are legacy Keycloak leftovers, unused by the Google flow, and should be removed rather than revived.

Edge cases & risks

Domain ownership must be proven (DNS TXT) before an org can claim it, or one tenant can hijack another's users. Users with membership in several orgs need a deterministic rule for which IdP wins.

AUTH-14

Provision and deprovision users automatically via SCIM

Proposed
Organisation adminCould
As an Organisation admin at a large OTA, I want SCIM 2.0 provisioning from Okta or Entra ID, so that starters and leavers are reflected in Hotel Universe without manual invitations.
Acceptance criteria
  1. Given SCIM is enabled with a bearer token issued to the org, when the IdP POSTs a new user, then a users record is created and added to organizations.memberIds without an invitation email.
  2. Given the IdP deactivates a user, when the PATCH arrives, then the member is removed from the org and their active sessions are revoked.
  3. Given the IdP pushes a group, when it maps to a role (AUTH/ORG RBAC), then members receive that role's scopes.
  4. Given a SCIM delete would remove the last member of an organisation, when it is processed, then it is refused with the same guard as DELETE /orgs/{orgId}/members/{memberId} ("Cannot remove the last member of an organization").
Implementation notes

A /scim/v2/Users and /scim/v2/Groups surface writing to the existing users and organizations collections; depends on RBAC (ORG-12) to make group mapping meaningful, since today every member has identical scopes.

Edge cases & risks

SCIM is only worth building after roles exist — without them, group sync can express nothing. A misconfigured IdP push can mass-remove members; changes should be rate-limited and reversible for a grace period.

AUTH-15

Protect my account with MFA

Proposed
Organisation adminShould
As an Organisation admin, I want a second factor on sign-in, so that a compromised email account cannot expose our supply file, comp sets and rate intelligence.
Acceptance criteria
  1. Given I enrol a TOTP authenticator, when I next sign in, then I am prompted for a six-digit code before a token is issued.
  2. Given I enrol, when enrolment completes, then I am shown ten single-use recovery codes exactly once, in the same pattern as the PAT secret dialog.
  3. Given I enter an incorrect code five times, when I try again, then the attempt is throttled and the failures appear in the login audit trail.
  4. Given the org requires MFA, when a member without MFA signs in, then they are forced into enrolment before reaching any data screen.
  5. Given I sign in through SSO where the IdP already asserts MFA, when the assertion includes the relevant AMR values, then Hotel Universe does not prompt a second time.
Implementation notes

Applies to password and magic-link paths; Google and SSO logins inherit the provider's factor. Enforcement flag stored per organisation next to tier.

Edge cases & risks

MFA on the human path does nothing for pat_ tokens, which bypass it entirely — the real credential risk stays with AUTH-08 and AUTH-19 until PATs gain expiry and rotation.

AUTH-16

See and revoke my active sessions and devices

Proposed
Organisation adminShould
As an Organisation admin, I want a list of active sessions with device, location and last-seen, so that I can end a session on a lost laptop without rotating everyone's access.
Acceptance criteria
  1. Given I open account security settings, when the page loads, then I see every active session with device/browser, approximate location, created-at and last-refresh time, with the current session marked.
  2. Given I revoke a session, when I confirm, then that session's refresh is rejected on its next 5-minute cycle and its token no longer authenticates.
  3. Given I choose "sign out everywhere", when I confirm, then all sessions except the current one are revoked and PATs are explicitly listed as unaffected.
  4. Given a session is revoked while its browser is mid-request, when the next call is made, then it receives 401 and the portal follows the AUTH-10 expiry flow.
Implementation notes

Requires server-side session records (jti plus device metadata) because JWTs are currently stateless and self-validating — revocation needs either a deny list in Redis (already available when REDIS_URL is set for rate limiting) or short-lived tokens per AUTH-19.

Edge cases & risks

Without a deny list, a stolen 7-day JWT stays valid for its full lifetime no matter what the UI claims — the feature must not promise revocation it cannot deliver.

AUTH-17

Review a login and security audit trail

Proposed
Ubio support agentShould
As a Ubio support agent, I want an authentication event log, so that I can answer "who signed in, from where, and when did this token last work?" during an incident.
Acceptance criteria
  1. Given any authentication event occurs (success, failure, logout, refresh failure, PAT created or deleted, token first use), when it completes, then a record is written with principal, org, IP, user agent, outcome and reason.
  2. Given I am a Ubio internal user, when I query the trail for a user or org over a date range, then results return within seconds and are paginated.
  3. Given an org admin views their own org's trail, when it renders, then only their org's events are visible and other tenants' events are never returned.
  4. Given the retention window has passed, when records age out, then they are purged automatically rather than growing unbounded.
Implementation notes

Mirrors the existing accessLogs design — a MongoDB time-series collection with timeField: time, metaField: meta, hourly granularity and a TTL (the data-access log uses one year). Distinct from accessLogs, which records hotels:view / metasearch:view style data access, not authentication.

Edge cases & risks

IP and user-agent capture is personal data under GDPR; retention and access must be documented alongside AUTH-22. High-frequency PAT traffic could dominate the collection — log first-use-per-day per token rather than every request.

AUTH-18

Issue service accounts that are not tied to a person

Proposed
Customer data engineerShould
As a Customer data engineer, I want credentials owned by a service rather than by my personal account, so that our nightly ingest does not break when I leave the company.
Acceptance criteria
  1. Given I create a service account, when it is saved, then it has its own name, owner team, description and an explicit scope set, and it is not attached to any users record.
  2. Given the human who created it is removed from the org, when the service account is used, then it keeps working.
  3. Given a service account holds one or more credentials, when I rotate one, then old and new secrets are both valid for a configurable overlap window before the old one is disabled.
  4. Given a service account is disabled, when any of its credentials is presented, then the request returns 401 and the event is logged.
  5. Given I try to grant a service account a scope my own role does not hold, when I save, then the grant is refused.
Implementation notes

Today accessTokens already produces principal.type: serviceAccount with displayName: "PAT <name>", but the record is created by a person under PAT_MANAGE and carries createdBy. This story separates the identity (service account) from the credential (token), and pairs naturally with the landing page's promise of "Scoped API keys — issue read-only tokens per agent or per workspace".

Edge cases & risks

Scope escalation is the main risk; the current PAT scope set is fixed (exports, lists, hotels, reviews, metasearch) rather than chosen, so genuinely read-only agent keys are not yet possible despite being advertised.

AUTH-19

Rotate and expire credentials

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want every credential to have an expiry and a rotation path, so that a leaked token has a bounded blast radius.
Acceptance criteria
  1. Given a PAT is created, when I choose an expiry (30/90/365 days or never, with never requiring an explicit confirmation), then the expiry is stored and shown in the "Expiration Time" column that is currently always blank.
  2. Given a PAT is within 14 days of expiry, when the org admin opens the tokens screen, then an expiring-soon warning is shown, and a notification is sent if notification preferences allow it.
  3. Given a PAT has expired, when it is presented, then the API returns 401 with a distinct machine-readable reason so integrations can log it clearly.
  4. Given I rotate a PAT, when the new secret is issued, then the old one remains valid for the overlap window and its last-used timestamp is visible so I can confirm the cutover.
  5. Given the access JWT lifetime is shortened, when the portal refreshes, then a rotating refresh token is issued and reuse of a consumed refresh token invalidates the whole session family.
Implementation notes

Extends accessTokens with expiresAt, lastUsedAt and rotatedFrom; the portal already has the UI column and the one-time-secret dialog to hang this on. Short-lived access tokens make AUTH-16 revocation real without a deny list.

Edge cases & risks

Introducing expiry on existing non-expiring PATs will break live integrations unless grandfathered and announced; treat the first cohort as never-expiring with a loud dashboard warning.

AUTH-20

Sign in with Microsoft

Proposed
Corporate travel buyerCould
As a Corporate travel buyer on Microsoft 365, I want a "Sign in with Microsoft" button, so that my whole team can onboard on day one without an SSO project.
Acceptance criteria
  1. Given I am on the sign-in screen, when I click "Sign in with Microsoft", then I complete an OIDC flow against Entra ID and return to a callback route equivalent to /googleAuthCallback.
  2. Given the flow succeeds, when the account is new, then user creation, pending-invitation processing and trial-org creation behave identically to the Google path.
  3. Given my Microsoft address matches an existing Google-created user, when I sign in, then the identities are linked to one users record rather than creating a duplicate.
  4. Given the Microsoft tenant denies consent, when I return, then the callback error state renders the provider's message.
Implementation notes

Config.authProvider is already a named setting and the scaffold has an AuthProviderRegistry, so a second provider is a registration plus a callback route rather than an auth rewrite.

Edge cases & risks

Identity linking by email address is only safe when the provider asserts a verified address; Entra guest accounts can present addresses from other tenants.

AUTH-21

Provision seats when people join

Proposed
Ubio finance opsCould
As a Ubio finance ops user, I want sign-in and invitation acceptance to consume a seat against the org's plan, so that billed seats and actual users stay in step.
Acceptance criteria
  1. Given an org's plan includes N seats, when a new member joins by invitation or SSO, then the seat count increments and is visible on the Billing screen next to the per-product usage lines.
  2. Given the org is at its seat limit, when another person accepts an invitation, then the join is refused with a message naming the limit and linking to Billing.
  3. Given a member is removed, when the removal completes, then the seat is released at the end of the billing period, matching the existing "Cancels on <date> — access continues until then" semantics.
  4. Given the org is on TRIAL, when seats are evaluated, then a small fixed allowance applies rather than an unlimited one.
Implementation notes

Would extend GET /billing/subscription ({tier, subscriptions, billing, usage}) with a seat dimension and render through the existing formatSubscriptionUsageLine / formatSubscriptionOverageLine helpers in utils/billingFormatters.ts.

Edge cases & risks

No usage is metered to Stripe today — quotas are computed internally from access logs — so seat billing needs a decision on whether seats are a Stripe quantity or another internally enforced cap. Blocking a join at the limit is a bad first experience; a soft overage line may sell better.

AUTH-22

Delete my account and erase my data

Proposed
Trial userShould
As a Trial user, I want to delete my account and have my personal data erased, so that I can exercise my GDPR rights after an evaluation.
Acceptance criteria
  1. Given I request deletion, when I confirm with a typed acknowledgement, then my users record is scheduled for erasure after a 14-day grace period during which I can cancel.
  2. Given I am the last member of an organisation, when deletion is confirmed, then I must first delete or transfer the organisation, mirroring the "Cannot remove the last member of an organization" guard.
  3. Given erasure runs, when it completes, then users fields, avatarUrl, invitations sent to my address and my authentication audit records are removed or irreversibly pseudonymised, while org-owned artefacts (lists, exports, access logs) are retained under the org with the actor anonymised.
  4. Given I request a copy of my data first, when the export completes, then it is delivered as a downloadable file before erasure proceeds (see ACCT-21).
  5. Given deletion is scheduled, when I sign in during the grace period, then I am shown the pending deletion and offered cancellation.
Implementation notes

accessLogs records principalId and is a time-series collection with a one-year TTL, so pseudonymisation there needs a rewrite-or-wait decision. Note that HotelReviewExport already strips the reviewer field for PII reasons — the same principle applies to account data.

Edge cases & risks

Erasing a createdBy on lists and tokens can orphan audit trails that other tenants' compliance teams rely on; pseudonymise rather than null out. Stripe customer records are a separate controller relationship and need their own deletion path.

ORG 7 built4 partial11 proposed

Organisations, members, invitations & permissions

An organisation is the tenancy boundary for everything in Hotel Universe: lists, exports, access tokens, subscriptions and usage all hang off organizations.memberIds and the org id embedded in the bearer token. A user can belong to several orgs and switches between them with POST /auth/switch-org followed by a full page reload. The defining gap in this epic is that there are no per-user roles — every member of an org holds the identical scope set, so the newest invitee can rename the org, delete lists, mint tokens and change the subscription; invitations are additionally gated by the wrong permission (LISTS_MANAGE rather than ORGS_MANAGE).

ORG-01

Create an organisation

Built
Organisation adminMust
As an Organisation admin, I want to create an organisation, so that my team's lists, tokens and subscription live in one tenancy separate from my personal trial.
Acceptance criteria
  1. Given I am authenticated, when I POST /orgs with a name of 1–255 characters, then an organisation is created with me as its sole member at tier TRIAL.
  2. Given the org is created, when I call GET /users/me/orgs, then it appears with {id, name, tier, billing?}.
  3. Given I submit an empty name or one over 255 characters, when validation runs, then the request is rejected with a 400 and no organisation is created.
  4. Given the org is brand new, when I open it, then it has no lists, no tokens, no invitations and no Stripe customer.
Implementation notes

POST /orgs in the API; the same code path is used by first-login auto-creation of "<name>'s Organization" (AUTH-07). The organizations document is {id, name, memberIds[], tier, createdAt, createdBy, updatedAt, billing?}.

Edge cases & risks

There is no portal screen for creating a second organisation — the endpoint exists but the UI only ever surfaces orgs you already belong to, so self-serve multi-tenant setup requires an API call or a support ticket.

ORG-02

Rename my organisation

Built
Organisation adminShould
As an Organisation admin, I want to rename the organisation, so that the auto-generated "<name>'s Organization" becomes something my team recognises in the switcher.
Acceptance criteria
  1. Given I open /settings, when the page loads, then the current organisation name is shown in an inline-editable field.
  2. Given I edit the name and save, when PUT /orgs/{id} succeeds, then the new name is reflected in the settings screen, the profile menu org selector and auth.userInfo.
  3. Given the save fails, when the error returns, then the previous name is restored in the field and an error toast is shown.
  4. Given I submit a blank name, when validation runs, then the save is refused before a request is sent.
Implementation notes

app/views/Settings.vueApiManager.updateOrganization(orgId, name)PUT /orgs/{id}, gated server-side by ORGS_MANAGE, which every JWT holds.

Edge cases & risks

Because every member has orgs:manage, any member can rename the org — including a guest invited only to view a list. Renames are not audited, so there is no record of who changed it.

ORG-03

Delete an organisation

Partial
Organisation adminCould
As an Organisation admin, I want to delete an organisation I no longer need, so that stale tenancies stop appearing in my org switcher and stop accruing data.
Acceptance criteria
  1. Given I hold ORGS_MANAGE, when I call DELETE /orgs/{id}, then the organisation is deleted.
  2. Given the org has an active Stripe subscription, when deletion is attempted, then it is refused until the subscription is cancelled.
  3. Given deletion succeeds, when I next call GET /users/me/orgs, then the org is absent and my lastOrganizationId no longer points at it.
  4. Given the org owns lists, exports and tokens, when it is deleted, then those artefacts are removed or tombstoned rather than left orphaned and reachable by id.
Implementation notes

DELETE /orgs/{id} exists in the API but there is no portal UI for itSettings.vue only offers rename. Criteria 2–4 describe behaviour that is not verified to exist and would need to be confirmed or added.

Edge cases & risks

Deleting an org while another member has a live JWT scoped to it leaves that token pointing at a missing tenancy; deletion should revoke sessions and PATs for the org. Cascade behaviour across lists, listEntries, exports, accessTokens and GCS export objects is the real work here.

ORG-04

Belong to more than one organisation

Built
Travel agency consultantMust
As a Travel agency consultant working across several client tenancies, I want one login that belongs to multiple organisations, so that I do not need a separate account per client.
Acceptance criteria
  1. Given I have accepted invitations from several organisations, when I call GET /users/me/orgs, then each membership is returned with its id, name, tier and billing summary.
  2. Given I am a member of several orgs, when I open the profile menu or the settings sidebar, then all of them are listed in the org Select.
  3. Given I am not a member of an organisation, when I attempt to switch to it, then POST /auth/switch-org returns 403.
  4. Given a membership is revoked while I am signed in, when I next refresh the org list, then that org disappears from the switcher.
Implementation notes

Membership is a plain array — organizations.memberIds — with no role or join-date metadata. SettingsMenu.vue and ProfileMenu.vue both render the org Select from ApiManager.getOrganizations().

Edge cases & risks

With no per-org roles, a consultant holds full administrative scope in every client org they join, including the ability to change that client's subscription. That is the single strongest argument for ORG-12.

ORG-05

Switch between organisations

Partial
Organisation adminMust
As an Organisation admin, I want to switch the active organisation from the topbar, so that searches, lists and billing screens all address the right tenancy.
Acceptance criteria
  1. Given I pick a different org in the selector, when POST /auth/switch-org succeeds, then a new token scoped to that org is stored and {token, organization} is applied.
  2. Given the switch completes, when the app settles, then all org-scoped caches are cleared — including the AI assistant transcript, which resets on org or user switch.
  3. Given I am not a member of the target org, when I attempt the switch, then a 403 is returned and the previous org stays active.
  4. Given writing the new token to localStorage fails, when the switch is attempted, then the org-switch storage failure toast is shown and the session is left in a consistent state.
  5. Given a Stripe deep link carries ?organizationId=, when the Billing screen loads, then the org is switched automatically before the subscription is rendered.
Implementation notes

ApiManager.switchOrganization(orgId) followed by window.location.reload() in SettingsMenu.vue; applyOrganizationFromQuery() / handleStripeRedirect() on Billing.vue; AiAssistantManager resets its sessionStorage state (hotel-universe:ai-assistant-chat, STORAGE_VERSION = 10) on switch.

Edge cases & risks

The full page reload is the named gap: it discards in-flight work, unsaved selections and scroll position, and it makes switching feel like signing in again. Some client state is not org-scoped at all — hotel-universe:selected-hotels holds huIds selected in the previous org.

ORG-06

See who is in my organisation

Built
Organisation adminMust
As an Organisation admin, I want a members list, so that I can see exactly who has access to our data.
Acceptance criteria
  1. Given I open /members, when the page loads, then GET /orgs/{orgId}/members returns {total, members:[{id, name, email, avatarUrl}]} and the table renders avatar, name and email.
  2. Given the request is in flight, when the page renders, then a skeleton table is shown rather than stale content.
  3. Given I switch organisation while the members request is in flight, when the response arrives, then the request-id guard discards it and the correct org's members are shown.
  4. Given the request fails, when the error returns, then an error state is rendered rather than an empty table implying the org has no members.
Implementation notes

app/views/Members.vue, reached from SettingsMenu.vue; the members endpoint is gated only by org membership, since the declared orgs:members:view scope is commented out in the permission set.

Edge cases & risks

The list shows no last-active date, no role and no indication of who holds which PATs, so an admin auditing access cannot tell a dormant account from an active one.

ORG-07

Remove a member

Built
Organisation adminMust
As an Organisation admin, I want to remove someone from the organisation, so that a leaver stops having access to our supply file and rate data.
Acceptance criteria
  1. Given I click Remove on a member row, when the confirmation dialog is accepted, then DELETE /orgs/{orgId}/members/{memberId} removes them and the table refreshes with a success toast.
  2. Given the member row is my own, when the table renders, then no Remove action is offered.
  3. Given I attempt to remove the only remaining member, when the request is processed, then it is refused with 400 "Cannot remove the last member of an organization".
  4. Given removal succeeds, when the removed user makes their next API call, then requests scoped to that org are refused.
Implementation notes

Members.vue with $confirm.require, ApiManager.removeMember(orgId, memberId), and a member_removed telemetry event.

Edge cases & risks

Criterion 4 is the weak one: the removed user's existing JWT stays valid until it expires (up to 7 days) and any PAT they created remains active and org-scoped, so removal is not immediate revocation. This is the clearest dependency on AUTH-16 and AUTH-19.

ORG-08

Invite a colleague by email

Built
Organisation adminMust
As an Organisation admin, I want to invite a colleague by email address, so that they can join our organisation and work on the same lists.
Acceptance criteria
  1. Given I open the invite modal on /members, when I enter an email and submit, then POST /invitations creates an invitation with {id, email, orgId, invitedBy, invitedByName, createdAt, expiresAt} and a 7-day expiry.
  2. Given the invitation is created, when SendGrid accepts it, then an email is sent with subject "You've been invited to join ${organizationName} on Hotel Universe" and a CTA to ${FRONTEND_URL}/invite/accept?token=${invitation.id}.
  3. Given SendGrid throws, when the send fails, then the invitation record is deleted so no dead invite is left pending, and I see an error.
  4. Given the invitee already exists as a user, when they accept, then the response carries userExists: true so the portal can route them to sign-in rather than sign-up.
  5. Given I invite an address that already has a pending invitation for this org, when I submit, then a duplicate invitation is not created.
Implementation notes

Members.vue invite modal → ApiManager.inviteUser(email); server-side sendInvitationEmail is the only email type the platform sends, with support@ubio.ai in the footer. Emits member_invited telemetry.

Edge cases & risks

The invitation is gated by the wrong scope (see ORG-11), and its id doubles as the acceptance token, so the identifier returned by GET /invitations is itself the bearer secret. There is no bounce handling — a typo'd address fails silently from the admin's point of view.

ORG-09

Accept an invitation

Partial
Data analystMust
As a Data analyst who has been invited, I want to click the link in my email and join the organisation, so that I can start working without a support conversation.
Acceptance criteria
  1. Given I open ${FRONTEND_URL}/invite/accept?token=<id>, when the page mounts, then InviteAccept.vue shows the processing state and calls POST /invitations/{id}/accept.
  2. Given the token is valid, when acceptance succeeds, then the response {success, message, orgId, orgName, userExists} drives a success screen naming the organisation.
  3. Given I do not yet have an account, when I then sign in with Google, then the pending invitation for my email is processed at first login and I land in the inviting org.
  4. Given the invitation has passed its 7-day expiresAt or has been cancelled, when I open the link, then the error state explains that the invitation is no longer valid and tells me to ask for a new one.
  5. Given someone other than the invitee opens the link, when acceptance is processed, then the join must be tied to the invited email address rather than granted to whoever holds the URL.
Implementation notes

app/layout/InviteAccept.vueApiManager.acceptInvite(token); the route is requiresAuth: false. POST /invitations/{id}/accept is unauthenticated — it is on the API's list of unauthenticated surfaces alongside /scrapers/*, /sources and /img/resize.

Edge cases & risks

Criterion 5 is the gap: the invitation id is guessable-adjacent, single-use semantics are unclear, and an unauthenticated accept endpoint means the URL alone is the credential. A forwarded invitation email grants org membership to the forwardee.

ORG-10

Track and cancel pending invitations

Built
Organisation adminShould
As an Organisation admin, I want to see which invitations are still outstanding and cancel the ones sent in error, so that nobody joins our org by accident.
Acceptance criteria
  1. Given I open /members, when the page loads, then the Pending invitations table lists email, status tag and invited-by from GET /invitations.
  2. Given I click Cancel on a pending invitation, when I confirm the dialog, then DELETE /invitations/{id} removes it and a toast confirms the cancellation.
  3. Given an invitation is cancelled, when the invitee opens their emailed link, then acceptance fails with the invalid-invitation error state.
  4. Given there are no pending invitations, when the section renders, then an empty state is shown rather than a bare table header.
Implementation notes

Members.vue pending-invitations table → ApiManager.getInvitations() and deleteInvitation(id), with $confirm.require on cancel.

Edge cases & risks

Expired invitations are not visibly distinguished from fresh ones beyond the status tag, and nothing prunes them, so the table accumulates dead rows on long-lived orgs.

ORG-11

Gate invitations on the right permission

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want invitation endpoints gated by an organisation-management permission rather than a list permission, so that membership changes are governed by the scope that actually means "administer this org".
Acceptance criteria
  1. Given the invitation endpoints, when their required scope is inspected, then POST /invitations, GET /invitations and DELETE /invitations/{id} require ORGS_MANAGE, not LISTS_MANAGE.
  2. Given a principal holds LISTS_MANAGE but not ORGS_MANAGE, when it calls POST /invitations, then the request is refused with 403.
  3. Given a PAT is presented, when it calls POST /invitations, then the request is refused, because PAT scopes deliberately exclude orgs:manage.
  4. Given the change ships, when existing portal JWT holders invite someone, then the flow still works, because JWT login scopes already include orgs:manage.
Implementation notes

The mis-scoping is documented as a known issue: invitations are gated by LISTS_MANAGE. The consequence today is that a PAT — which holds lists:manage but not orgs:manage — can add members to an organisation, which is a privilege-escalation path from a leaked API key to full tenancy access.

Edge cases & risks

Fixing the scope is a one-line change with a real blast radius: any customer automation currently inviting users with a PAT breaks. Announce it, and note that users:manage is declared but never enforced anywhere, so a broader scope audit is warranted at the same time.

ORG-12

Assign roles to members

Proposed
Organisation adminMust
As an Organisation admin, I want to assign roles such as owner, admin, member, billing and read-only, so that an analyst cannot cancel our subscription or delete the team's lists.
Acceptance criteria
  1. Given roles exist, when I open /members, then each row shows a role and I can change it from a select.
  2. Given a member holds the read-only role, when they attempt POST /search/save, POST /lists/upload, DELETE /lists/{id} or any export creation, then the request is refused with 403 and the portal hides or disables those actions.
  3. Given a member holds the billing role, when they open /billing, then checkout, change-tier, cancel and reactivate are available to them and to owners/admins only.
  4. Given I am the only owner, when I try to demote myself, then the change is refused until another owner exists.
  5. Given a role change is saved, when the affected user's token is next refreshed, then their scopes reflect the new role without requiring them to sign out.
Implementation notes

Today membership is a flat memberIds[] array and every member receives the identical JWT scope set (orgs:manage, exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage). This story replaces the array with member records carrying a role, and maps roles to those existing scopes plus the currently commented-out orgs:members:manage / orgs:members:view.

Edge cases & risks

Scopes are baked into the JWT at mint time, so a demotion does not take effect until the next refresh — up to 5 minutes, or 7 days if refresh is failing. Migration must default every existing member to a role that does not reduce their access overnight.

ORG-13

Define custom roles

Proposed
OTA product managerCould
As an OTA product manager, I want to define a custom role from the available scopes, so that our supply team can manage lists without touching billing or tokens.
Acceptance criteria
  1. Given I am an owner, when I create a custom role, then I choose a name and a subset of the available scopes (hotels:view, reviews:view, metasearch:view, lists:manage, exports:manage, pat:manage, orgs:manage).
  2. Given I assign a custom role to a member, when their token refreshes, then it carries exactly the chosen scopes.
  3. Given a scope is removed from a role in use, when the change is saved, then affected members lose that capability and a warning names how many are affected.
  4. Given I try to create a role granting a scope I do not hold, when I save, then the request is refused.
  5. Given a role is still assigned to at least one member, when I delete it, then deletion is blocked until members are reassigned.
Implementation notes

Builds directly on the eight declared permissions; the portal already has per-tier capability helpers in utils/accessTier.ts that would gain a per-scope sibling for rendering.

Edge cases & risks

Custom roles multiply the gating matrix already created by tiers (trial/paid/full) and route meta (requiresMetasearchAccess, requiresUbioAccess); without a single evaluation helper the portal will drift out of step with the API.

ORG-14

Restrict access to specific lists and products

Proposed
OTA supply managerCould
As an OTA supply manager, I want to scope a member's access to specific lists or to a single product, so that a contractor working on one market cannot export our entire supply file.
Acceptance criteria
  1. Given a member is granted access to list A only, when they call GET /lists, then only list A is returned and GET /lists/{other} returns 403, matching the existing cross-org 403 behaviour.
  2. Given a member is scoped to the Rich Content product only, when they open /metasearch or /direct-prices, then the routes refuse entry the same way requiresMetasearchAccess already does.
  3. Given a member has no access to a list, when an export of that list is attempted, then POST /lists/{listId}/exports is refused.
  4. Given list-level restrictions are active, when the AI assistant answers "show me my lists", then it returns only permitted lists.
Implementation notes

lists already carry orgId and createdBy and the portal already filters "My lists" versus "All lists" (/lists/my), so a per-list access-control list is an incremental step. Product scoping maps onto the existing BillingProduct split (smartFeed, metasearch, richContent).

Edge cases & risks

Dynamic lists re-run a saved searchQuery, so a restricted member could infer excluded data through search even when list access is denied; search itself would need the same scoping to close that hole.

ORG-15

Let colleagues join automatically by email domain

Proposed
Organisation adminShould
As an Organisation admin, I want anyone signing in with our verified company domain to join our organisation automatically, so that new colleagues do not each land in an isolated trial org.
Acceptance criteria
  1. Given I add example.com and prove ownership by DNS TXT record, when verification succeeds, then the domain is marked verified on the organisation.
  2. Given domain capture is enabled and a user signs in with @example.com, when first login completes, then they join the existing organisation instead of triggering "<name>'s Organization" creation.
  3. Given the mode is set to request-to-join instead of auto-join, when such a user signs in, then an admin sees a pending join request they can approve or decline.
  4. Given the domain is a public provider such as gmail.com, when I try to add it, then it is rejected.
  5. Given a user already belongs to another organisation, when they sign in with a captured domain, then they are offered the org as an additional membership rather than moved silently.
Implementation notes

Hooks the same first-login branch that today auto-creates a trial org, checking a verified-domains list on organizations before falling back. The internal isUbee() check (email contains @ub.io, portal also accepts ubio.ai) is a crude precedent for domain-based membership and should not be extended as-is.

Edge cases & risks

Domain verification is mandatory — without it, one tenant can capture another's users. Auto-join plus flat scopes (ORG-12 unbuilt) means any new starter arrives with full administrative rights, so this should not ship before roles.

ORG-16

Transfer organisation ownership

Proposed
Ubio support agentShould
As a Ubio support agent, I want a supported way to transfer ownership of an organisation, so that a customer whose founder has left is not stranded with an unmanageable tenancy.
Acceptance criteria
  1. Given I am the owner, when I nominate another member as owner, then they receive a request and the transfer completes only on their acceptance.
  2. Given the transfer completes, when roles are re-evaluated, then the previous owner becomes an admin and the new owner holds billing and deletion rights.
  3. Given the nominated member is not in the organisation, when I nominate them, then the request is refused.
  4. Given the nomination is not accepted within 7 days, when it expires, then ownership is unchanged and the requester is notified.
  5. Given an organisation has an active Stripe subscription, when ownership transfers, then the Stripe customer stays attached to the organisation, not to the departing person.
Implementation notes

Depends on ORG-12 for the concept of an owner; organizations.createdBy is the closest thing today and is never used for authorisation. The expiry pattern can reuse the invitation model's 7-day expiresAt.

Edge cases & risks

Support-initiated transfers need their own audit record and a strong identity check, or they become a social-engineering path into a customer's whole data set.

ORG-17

Review an organisation audit log

Proposed
Organisation adminShould
As an Organisation admin, I want a log of administrative actions in my organisation, so that I can answer "who deleted that list?" and satisfy our internal controls.
Acceptance criteria
  1. Given any administrative action occurs (member invited, member removed, invitation cancelled, org renamed, token created or deleted, subscription changed, list deleted, export created), when it completes, then an entry is recorded with actor, action, target, timestamp and source (portal, API, MCP).
  2. Given I open the audit screen, when I filter by actor, action type or date range, then matching entries are returned with pagination.
  3. Given I export the log, when the export completes, then I receive a CSV or JSON file covering the filtered range.
  4. Given a member has been removed, when I view the log, then their historic actions remain attributed rather than disappearing.
  5. Given a non-admin member opens the screen, when the route resolves, then access is refused.
Implementation notes

The data-access side already exists — accessLogs is a time-series collection recording hotels:view, reviews:view, metasearch:view, lists:create, export:create: and export:download:, surfaced on /usage via GET /stats/. This story adds administrative events and a per-org viewer, and the portal already emits close analogues as telemetry (member_invited, member_removed, access_token_created, token_deleted, subscription_cancellation_scheduled) to POST /portal-events.

Edge cases & risks

Portal telemetry is not an audit log — it is client-emitted, best-effort and flushed on a 5-second timer with keepalive, so it can be dropped or forged. Audit entries must be written server-side at the point of the mutation.

ORG-18

Invite external collaborators as guests

Proposed
Hotel ownerCould
As a Hotel owner, I want to give my revenue consultant time-limited guest access to one list and its price matrix, so that they can advise me without seeing my whole account.
Acceptance criteria
  1. Given I invite a guest, when I choose the resources and an expiry date, then the invitation grants only those resources for that period.
  2. Given a guest signs in, when they open the portal, then only permitted screens are reachable and Settings, Members, Billing and Tokens are hidden.
  3. Given the guest's access expires, when they next sign in, then access is refused and the membership is shown as expired in the members list.
  4. Given a guest attempts an export of a resource they can only view, when the request is made, then it is refused with 403.
  5. Given a guest belongs to their own organisation as well, when they switch orgs, then their guest tenancy is clearly labelled as guest in the switcher.
Implementation notes

Requires ORG-12 (roles) and ORG-14 (per-list scoping) first; the members list would gain a guest section distinguishing full members from time-boxed collaborators.

Edge cases & risks

Guests count towards seat maths (AUTH-21) unless explicitly excluded, and a guest with metasearch view can consume the host org's metasearch allowance — which triggers the metasearch_topup_required 402 for everyone.

ORG-19

Organise a large tenancy into workspaces

Proposed
OTA product managerCould
As an OTA product manager at a company with regional supply teams, I want workspaces or sub-teams inside one organisation, so that EMEA and APAC lists do not clutter each other while billing stays central.
Acceptance criteria
  1. Given workspaces exist, when I create one, then it has a name and a member set drawn from the organisation.
  2. Given a list is created inside a workspace, when another workspace's members open /lists, then it is not shown to them.
  3. Given billing is viewed, when the subscription renders, then it remains organisation-level with per-workspace usage attribution on the Usage screen.
  4. Given a member is removed from a workspace, when they open a list in it, then access is refused.
  5. Given an organisation has no workspaces, when a member uses the portal, then behaviour is exactly as today with no extra navigation level.
Implementation notes

Would add a workspace dimension to lists, exports and accessLogs.meta alongside orgId, and a second-level selector in SettingsMenu.vue. The portal's own design guideline — top bar for main sections, left sidebar for the second level, tabs for the third, URLs addressing resources — constrains where the switcher can live.

Edge cases & risks

Adding a tenancy level below the org affects every scoped query and every access-log aggregation; the /stats aggregation is per-org today and would need a workspace group-by. This is the most invasive story in the epic and should follow, not precede, roles.

ORG-20

Enforce seat limits from the plan

Proposed
Ubio finance opsCould
As a Ubio finance ops user, I want member counts checked against the plan's seat allowance, so that large teams on small plans are surfaced instead of quietly using unlimited seats.
Acceptance criteria
  1. Given a plan defines a seat allowance, when the members screen renders, then it shows "N of M seats used".
  2. Given the organisation is at its allowance, when an admin sends another invitation, then it is blocked with a message linking to Billing, or accepted with an explicit overage confirmation depending on plan configuration.
  3. Given a member is removed, when the seat count recalculates, then the freed seat is available immediately for invitation purposes even if billing settles at period end.
  4. Given the org is on TRIAL, when seats are evaluated, then a fixed small allowance applies.
  5. Given the plan is upgraded, when the new tier is active, then blocked invitations can be re-sent without recreating them.
Implementation notes

Reads the plan from GET /billing/subscription and renders through the existing formatSubscriptionUsageLine / formatSubscriptionOverageLine helpers. Pairs with AUTH-21; this story is the org-side enforcement, that one is the sign-in-side provisioning.

Edge cases & risks

All three paid plans currently grant identical access — there is a standing TODO to decide how much to gate on tier — so seat limits would be the first hard plan differentiator and need commercial sign-off before enforcement.

ORG-21

Invite many people at once from a CSV

Proposed
Organisation adminCould
As an Organisation admin onboarding a 40-person supply team, I want to upload a CSV of email addresses, so that I do not send forty invitations one modal at a time.
Acceptance criteria
  1. Given I upload a CSV of addresses on /members, when it is parsed, then I see a preview of valid rows, duplicates, existing members and malformed addresses before anything is sent.
  2. Given I confirm, when the batch is processed, then one invitation is created per valid address and a summary reports sent, skipped and failed counts.
  3. Given some sends fail at SendGrid, when the batch completes, then those invitations are deleted (matching the single-invite behaviour) and listed as failed with a retry action.
  4. Given the file is not text/csv or exceeds the size limit, when I upload it, then it is rejected with the expected/received detail already used by list upload.
  5. Given seat limits are enforced, when the batch would exceed the allowance, then I am told before any invitation is sent.
Implementation notes

Reuses the CSV machinery already proven for lists: PrimeVue FileUpload (basic, auto, text/csv, 1 MB) and csv-parse with columns: true, plus the 406 UploadListError shape with expected/received detail.

Edge cases & risks

Bulk invitation is a spam vector; rate-limit per org per day and require a verified domain or an established subscription. Extra CSV columns should be ignored, consistent with the list-upload promise "No need to reshape it first".

ORG-22

Resend and chase pending invitations

Proposed
Organisation adminShould
As an Organisation admin, I want to resend an invitation and see when it was last sent, so that an invite lost to a spam filter does not silently stall onboarding.
Acceptance criteria
  1. Given a pending invitation, when I click Resend, then the email is sent again, expiresAt is extended by 7 days from now, and a lastSentAt timestamp is displayed.
  2. Given I resend within a short cool-off window, when I click again, then the action is throttled with a clear message.
  3. Given an invitation has been pending for 3 days, when the reminder job runs, then a single reminder email is sent to the invitee and the admin sees that it was sent.
  4. Given an invitation has expired, when I open the row, then Resend issues a fresh invitation rather than reviving the expired record.
  5. Given SendGrid reports a hard bounce, when the status updates, then the invitation row shows Bounced and no further reminders are sent.
Implementation notes

Extends invitations with lastSentAt and a reminder counter, and adds a second SendGrid template alongside sendInvitationEmail — the platform's only transactional email today. The scrapers package's PeriodicTaskRunner.schedule(task, interval, name) is an existing in-house pattern for the reminder job.

Edge cases & risks

Extending expiresAt on resend means an invitation URL can live far longer than 7 days, widening the window on the unauthenticated accept endpoint (ORG-09). Bounce handling requires configuring SendGrid event webhooks, which do not exist today.

ACCT 6 built4 partial12 proposed

User account, preferences & accessibility

This epic covers the individual person's experience of the product rather than their organisation's: who they appear as, how the portal looks and remembers their choices, and whether the product is usable with a keyboard, a screen reader, a phone or a language other than English. Today there is real polish here — a bespoke "Cosmic Calm" PrimeVue theme with dark mode wired up before app boot, a driver.js onboarding tour with a server-side dismissal flag, and a dozen persisted client-state keys — sitting alongside two significant absences: no i18n whatsoever (all copy is hard-coded English) and no systematic accessibility work on the dense results table, the MapLibre maps and the price heat-map matrix.

ACCT-01

See who I am signed in as

Built
Data analystMust
As a Data analyst, I want my name, email and current organisation visible in the topbar, so that I always know which identity and tenancy my actions apply to.
Acceptance criteria
  1. Given I open the profile menu, when it renders, then my name and email from GET /users/me are shown along with the organisation selector.
  2. Given the menu is open, when I look at the footer, then it shows Portal: v{__PORTAL_VERSION__} and API: v{status.version} from GET /status.
  3. Given the menu is open, when I use it, then Settings, Members, Billing, Usage, Access tokens and Logout are all reachable.
  4. Given GET /status fails, when the menu renders, then the portal version is still shown and the API version degrades gracefully rather than breaking the menu.
Implementation notes

components/.../ProfileMenu.vue in the topbar; version strings come from the __PORTAL_VERSION__ build define and ApiManager.getStatus().

Edge cases & risks

The version footer is the fastest triage tool support has, but it is buried in a menu — when a customer reports a bug there is no copy-diagnostics action to capture portal version, API version, org id and tier in one click.

ACCT-02

Switch between light and dark

Built
Revenue managerShould
As a Revenue manager staring at price matrices all day, I want a dark theme, so that long sessions are comfortable.
Acceptance criteria
  1. Given I click the dark mode toggle, when it flips, then .dark is toggled on <html> and PrimeVue re-themes immediately via darkModeSelector: '.dark'.
  2. Given I toggle the theme, when the change is applied, then the preference is persisted to localStorage under darkMode.
  3. Given I reload the page, when the app boots, then the stored preference is applied in app/entrypoint.ts before the app mounts, so there is no flash of the wrong theme.
  4. Given no preference has ever been stored, when the app boots, then prefers-color-scheme: dark decides the initial theme.
  5. Given localStorage is unavailable (private mode, blocked storage), when the toggle is used, then the theme still changes for the session and no error is thrown.
Implementation notes

components/app/DarkModeToggle.vue plus the pre-boot initialiser in app/entrypoint.ts; the safe storage wrappers live in managers/storage/ (LocalStorageManager, getItem/setItem/loadJson/saveJson).

Edge cases & risks

The preference is per-browser, not per-account, so it does not follow a user across devices — see ACCT-13. Once set explicitly it never re-follows a change in the OS setting, because there is no "system" third state.

ACCT-03

Read a consistent, branded interface

Built
ProspectShould
As a Prospect evaluating the product, I want a coherent visual system across the landing page and the portal, so that the product feels like one considered thing rather than a stack of admin screens.
Acceptance criteria
  1. Given any portal screen, when it renders, then it uses the custom Aura preset ("Cosmic Calm") with the OKLCH yellow primary ramp and warm-neutral or warm-charcoal surface ramps.
  2. Given I move between the landing page and the authenticated shell, when the theme applies, then colours, radii and typography are continuous.
  3. Given a screen has no data yet, when it renders, then it uses the shared EmptyState.vue (glass or plain variant) rather than a bespoke message block.
  4. Given a screen is loading, when data is in flight, then a skeleton is shown and no stale content remains visible, per the portal's own README guideline.
Implementation notes

Preset configured in app/app.ts; stylesheets are stylesheets/index.css, portal.css (1904 lines), landing.css, layout/layout.css, layout/menu.css, tour.css. Shared states: EmptyState.vue, ErrorState.vue, UpgradeRequired.vue, Loading.vue.

Edge cases & risks

A 1904-line portal.css alongside Tailwind 4 and PrimeVue tokens is three styling systems in one app; ad-hoc overrides there are the most likely source of dark-mode and contrast regressions (ACCT-18).

ACCT-04

Have the portal remember how I work

Partial
Data analystShould
As a Data analyst, I want the portal to remember my search mode, panel states and current selection, so that a refresh does not undo my setup.
Acceptance criteria
  1. Given I switch between "Ask the assistant" and "Build with filters", when I return later, then my choice is restored from hotel-universe:search-mode.
  2. Given I have hotels selected, when I reload, then the selection is rehydrated from the sessionStorage key hotel-universe:selected-hotels.
  3. Given I expanded the metasearch Advanced Options, when I search again, then the panel state is restored from metasearch-filter-panels.
  4. Given I collapsed the AI assistant sidebar, when I return, then it stays collapsed via hotel-universe:ai-assistant-sidebar-collapsed.
  5. Given a stored value is corrupt or from an older schema version, when it is read, then it is discarded safely rather than breaking the screen.
Implementation notes

Persisted keys today: darkMode, ubio_auth_token, ubio_last_token_refresh, hotel-universe:search-mode, hotel-universe:selected-hotels, hotel-universe:ai-assistant-chat (STORAGE_VERSION = 10), hotel-universe:ai-assistant-sidebar-collapsed, directPricingActiveBatches (TTL 24 h), metasearch-filter-panels, redirectUrl. Read and written through LocalStorageManager / SessionStorageManager.

Edge cases & risks

The named gap: none of this is per-user or per-org on the server, so preferences do not follow a person between devices, and on a shared browser the next signed-in user inherits the previous user's selection and assistant transcript (see AUTH-05). Only the assistant state resets on org switch.

ACCT-05

Be shown around on my first visit

Built
Trial userShould
As a Trial user, I want a short guided tour on first sign-in, so that I understand where search, lists, prices and direct rates live before I start clicking.
Acceptance criteria
  1. Given GET /users/me returns initialSignUp: true, when AppLayout.vue mounts, then the driver.js tour starts and welcome_tour_started is tracked.
  2. Given the tour runs, when it advances, then it walks 8 steps across /search, /lists, /metasearch and /direct-prices, anchored to [data-tour-step] elements and forcing the correct search mode by clicking .pr-search-mode-toggle where needed.
  3. Given I complete or dismiss the tour, when it is destroyed, then welcome_tour_completed or welcome_tour_dismissed is tracked, POST /users/me/dismiss-initial-tour is called and I am returned to /search.
  4. Given I sign in again after dismissing, when the layout mounts, then the tour does not reappear.
  5. Given a step's anchor element never appears, when the 1.5 s polling timeout expires, then the tour advances or ends without blocking the UI.
Implementation notes

Tour hosted in app/layout/AppLayout.vue; options overlayOpacity 0.4, popoverOffset 20, stageRadius 6, overlayClickBehavior: 'none', styles in stylesheets/tour.css with classes hu-tour-popover / hu-tour-welcome. Unused anchors already exist for lists-sidebar, prices-results, assistant-empty, assistant-composer, search-ai-composer, ai-search-examples.

Edge cases & risks

The tour navigates between routes and clicks real controls, so a slow API or a tier-gated route (/metasearch bounces trial users to Billing) can strand it mid-sequence. There is no way to replay it deliberately once dismissed.

ACCT-06

Reach the tour and the product on a small screen

Partial
Hotel ownerShould
As a Hotel owner checking whether I am being undercut, I want the portal to work properly on my phone, so that I can act on a rate problem away from my desk.
Acceptance criteria
  1. Given I open the portal below 992px, when the layout renders, then the mobile Drawer menu provides full navigation.
  2. Given I am on the search screen on a narrow viewport, when I use it, then the selection rail collapses and can be reopened.
  3. Given I am a first-time user on a phone, when the layout mounts, then the onboarding tour is skipped — the current behaviour, and the gap this story records.
  4. Given the tour is skipped, when I land on /search, then I should still be offered an equivalent lightweight introduction rather than nothing at all.
Implementation notes

AppLayout.vue + AppMenu.vue / composables/layout.ts (staticMenuMobileActive, toggleMenu) handle mobile navigation; the tour is explicitly skipped when window.innerWidth < 992. The selection rail is collapsible on mobile with ResizeObserver-measured height.

Edge cases & risks

Navigation being responsive is not the same as the product being usable: the results table, the competitor price matrix and the direct-rates calendar are dense horizontal layouts, and the MapLibre location picker with its radius and area-scale sliders is difficult on touch. See ACCT-22.

ACCT-07

Live with a profile sourced from Google

Partial
Organisation adminCould
As an Organisation admin, I want to understand where my displayed name and avatar come from, so that I know why I cannot change them in the portal.
Acceptance criteria
  1. Given I signed in with Google, when my user record is created, then name and avatarUrl are taken from the Google userinfo response.
  2. Given my name and avatar are shown in the profile menu and in the members table, when they render, then they reflect that stored value.
  3. Given I change my name at Google, when I sign in again, then the portal should reflect the updated profile rather than keeping the value captured at first login.
  4. Given no avatar is available, when the members table renders, then a fallback avatar is shown rather than a broken image.
Implementation notes

users {id, googleId?, email, name, avatarUrl, lastOrganizationId?, createdAt, updatedAt}; there is no profile-edit endpoint and no profile screen — /settings edits the organisation name only.

Edge cases & risks

Criterion 3 is unverified — if the profile is only captured at first login, stale names persist indefinitely, and the auto-generated org name "<name>'s Organization" freezes that stale name in place too.

ACCT-08

Get clear, consistent feedback on my actions

Built
Trial userShould
As a Trial user, I want every action to confirm itself and every destructive action to ask first, so that I can move quickly without fear.
Acceptance criteria
  1. Given I complete an action such as saving a list, copying an id or creating a token, when it succeeds, then a toast appears in the global <Toast position="bottom-left">.
  2. Given I trigger a destructive action such as deleting a list, removing a member, cancelling an invitation or disabling direct pricing, when it starts, then a $confirm.require dialog asks me to confirm.
  3. Given an action fails, when the error returns, then the toast carries an actionable message and, for uploads, server HTML is sanitised out.
  4. Given a bulk action partly succeeds, when it completes, then a partial-warning toast states how many items succeeded and how many did not.
Implementation notes

<Toast> and <ConfirmDialog> are mounted globally in AppLayout.vue; copy constants are almost entirely inline, with only app/copy/toastTexts.ts (RICH_CONTENT_SUBSCRIBE_MESSAGE) extracted.

Edge cases & risks

Toasts are transient and bottom-left; a user who looks away misses the only record that an export started or a subscription changed. There is no notification centre, which is what ACCT-12 addresses.

ACCT-09

Read dates and numbers in a familiar format

Partial
Corporate travel buyerShould
As a Corporate travel buyer, I want dates, prices and large numbers formatted the way I expect, so that I do not misread 03/04 as the wrong day or a comma as a decimal point.
Acceptance criteria
  1. Given a date is displayed, when it renders, then it uses a consistent format across lists, exports, invoices, direct-rate calendars and price matrices.
  2. Given a price is displayed, when it renders, then it uses the currency's own symbol and separators via the shared formatCurrency helper.
  3. Given a large review count is displayed, when it renders, then it is abbreviated consistently (for example 1.2k on the Ratings & Reviews tab).
  4. Given my locale differs from the developer's, when dates render, then the format follows my preference rather than a value hard-coded in the source.
Implementation notes

utils/formatters.ts uses toLocaleDateString('en-GB'|'en-US') with the locale hard-coded per call site; billing formatting lives in utils/billingFormatters.ts (formatCurrency, formatRenewalDate, prettifyPlan, formatTierLabel).

Edge cases & risks

Mixing en-GB and en-US in one product means the same date can appear in two orders on two screens — a genuine misreading risk on check-in dates and cancellation deadlines. Criterion 4 needs ACCT-13.

ACCT-10

Understand what my organisation is consuming

Built
Organisation adminShould
As an Organisation admin, I want to see usage by action over a date range, so that I can tell whether we are approaching our trial or plan limits.
Acceptance criteria
  1. Given I open /usage, when the page loads, then start and end date pickers default to the last 30 days and GET /stats returns [{action, uniqueHotelCount}].
  2. Given results return, when the table renders, then action codes are mapped to descriptions (lists:create, metasearch:view, hotels:view, reviews:view, export:create:, export:download:) with a unique-hotels count per row.
  3. Given I belong to several organisations, when I use the org selector, then usage for the selected org is shown.
  4. Given there is no usage in the range, when the table renders, then "No usage data found" is displayed rather than an empty grid.
Implementation notes

app/views/Usage.vue + UsageTable.vueApiManager.getOrganizationStats({organizationId, startDate, endDate}); internal @ub.io users see all organisations via getAllOrganizations().

Edge cases & risks

The metric is distinct hotels per action, not request count, so it does not explain rate-limit 429s on GET /metasearch or a metasearch allowance exhaustion; a user diagnosing a 402 will not find the answer here.

ACCT-11

Edit my display name and avatar

Proposed
Organisation adminShould
As an Organisation admin, I want to set my own display name and avatar, so that colleagues recognise me in the members list even though my Google profile says something else.
Acceptance criteria
  1. Given I open a new account profile screen, when I edit my display name and save, then PATCH /users/me persists it and the profile menu and members table update.
  2. Given I upload an avatar, when it is saved, then it is stored and served at a bounded size, reusing the existing image-resize path.
  3. Given I clear my custom name, when I save, then the value falls back to the identity-provider name.
  4. Given I submit a name that is empty or over the allowed length, when I save, then validation refuses it with an inline message.
  5. Given my org uses SCIM, when the IdP pushes a name, then the org policy decides whether the IdP value wins over my local override.
Implementation notes

Adds a genuine account screen — today /settings is organisation settings only. Avatar hosting could reuse the img app (GET /img/resize?hash&w&h, sharp cover-resize, JPEG q75), though that endpoint is currently unauthenticated and would need gating for user-supplied images.

Edge cases & risks

User-uploaded images introduce a moderation and malware surface the platform does not have today; restrict to image MIME types, re-encode server-side, and never serve the original bytes.

ACCT-12

Choose which notifications I receive

Proposed
Data analystShould
As a Data analyst, I want to opt in to notifications for export-ready, quota warnings and a weekly digest, so that I do not have to keep the exports drawer open to know when a job finishes.
Acceptance criteria
  1. Given I open notification preferences, when the page loads, then I can toggle each category independently: export ready, export failed, quota or allowance warning, list refresh diff available, weekly digest.
  2. Given export-ready notifications are on, when POST /lists/{listId}/exports completes and the export reaches ready, then I receive one email with a link to the exports drawer.
  3. Given quota warnings are on, when my trial hotel views reach 80% of ACCESS_TIER_TRIAL_LIMIT or my metasearch allowance nears exhaustion, then I receive a warning naming the limit.
  4. Given I disable a category, when the corresponding event occurs, then no message is sent and none is queued.
  5. Given a message fails to send, when the failure is recorded, then it is retried with backoff and never blocks the underlying job.
Implementation notes

The platform sends exactly one email type today (sendInvitationEmail via SendGrid) and has no welcome, export-ready or quota-warning emails. The signals all exist server-side: export status transitions, accessLogs-derived quota maths, metasearchAllowance.exhausted, and listDiffManager new-hotel counts.

Edge cases & risks

Export links must not embed credentials — the download URL is a 15-minute signed GCS URL, so an email should link to the portal, not to the signed URL. Digest volume must respect the 30-day export expiry so links in old digests fail gracefully.

ACCT-13

Set my default currency, locale and units

Proposed
Revenue managerShould
As a Revenue manager in Spain, I want to set EUR and my locale once, so that every price screen opens in my currency instead of defaulting to GBP.
Acceptance criteria
  1. Given I set a default currency, when I open Metasearch, then the currency field is pre-filled with it instead of the GBP default.
  2. Given I set a default locale, when I open Metasearch, then the locale field uses it instead of the gb default.
  3. Given I set a distance unit preference, when competitor distances and surroundings render, then they use kilometres or miles accordingly.
  4. Given I set a date format preference, when any date renders, then utils/formatters.ts uses it rather than a hard-coded en-GB or en-US.
  5. Given I have no preference set, when screens render, then today's defaults apply unchanged.
Implementation notes

Preferences stored on the user record and applied as defaults in MetasearchFilter.vue (currency list from utils/currencies.ts, locale list from utils/locales.ts), the competitor and surroundings components, and utils/formatters.ts. Note a documentation trap: docs/docs.http states a locale default of gb while the API code uses us.

Edge cases & risks

Currency is not just presentation — GET /metasearch takes it as a request parameter and providers return prices in it, so a default change alters what is fetched and cached, not just what is displayed. TripAdvisor and Booking scrapers default to USD and MILES internally, which can leak into displayed source data.

ACCT-14

Choose my landing screen

Proposed
Revenue managerCould
As a Revenue manager who lives in the price matrix, I want to choose which screen opens after sign-in, so that I stop navigating away from search every morning.
Acceptance criteria
  1. Given I set a default screen, when I sign in, then / redirects there instead of to HotelsSearch.
  2. Given my chosen screen requires access I no longer have (for example requiresMetasearchAccess), when I sign in, then I land on the default search screen with an explanatory message rather than bouncing to Billing.
  3. Given I arrived via a deep link, when sign-in completes, then the stored redirectUrl wins over my default screen.
  4. Given I have set no preference, when I sign in, then behaviour is unchanged.
Implementation notes

The Home route is a redirect: { name: 'HotelsSearch' } in RouterManager.ts; this makes the redirect target a stored preference resolved after access.refreshAccessInfo() so tier gating is known.

Edge cases & risks

Interacts with three existing guards (requiresAuth, requiresMetasearchAccess, requiresUbioAccess) and with the onboarding tour, which assumes it starts on /search — the tour must override the preference on first sign-in.

ACCT-15

Save and reuse filter presets

Proposed
Travel agency consultantShould
As a Travel agency consultant, I want to save a named set of search filters and reapply it in one click, so that our standard policy screen ("4★+, refundable, walkable to the office") is not rebuilt from scratch each time.
Acceptance criteria
  1. Given I have filters applied, when I click Save preset and name it, then the current FILTER_DEFS state is stored against my user.
  2. Given I open the filter bar, when I pick a saved preset, then hotelManager.applyFilters() runs, the URL query is rewritten and results reload.
  3. Given a preset includes a filter my tier cannot use (for example richContentSubscribed, which is paid-tier only), when I apply it, then that filter is dropped with a notice rather than silently failing.
  4. Given I delete a preset, when I confirm, then it is removed and any list created from it is unaffected.
  5. Given a preset is shared with my organisation, when a colleague applies it, then they get identical filters subject to their own tier gating.
Implementation notes

Distinct from dynamic lists: a saved search today is a list (POST /search/save creates one, auto-named from the filters, e.g. "name:hotel, country:ES, city:San"). A preset is lighter — filter state only, no entries, no diffing — and would serialise the same query object that queryToFilters and applyFilters already round-trip through the URL.

Edge cases & risks

Filter definitions evolve; a preset saved against an older FILTER_DEFS set must degrade gracefully when a key disappears. Presets containing map geometry (geometry, geometryMultiplier) reference a place id that may change upstream.

ACCT-16

Drive the portal from the keyboard

Proposed
Data analystCould
As a Data analyst doing repetitive list triage, I want keyboard shortcuts and full keyboard navigation, so that I can approve, reject and page through the review queue without a mouse.
Acceptance criteria
  1. Given I press a global shortcut key, when it fires, then I can jump to search, open the assistant, and focus the filter bar, with a discoverable shortcut help overlay.
  2. Given I am in the review queue, when I use arrow keys and single-key actions, then I can move between rows and approve or reject the focused row.
  3. Given I tab through any screen, when focus moves, then the order is logical and the focus ring is visible against both light and dark surfaces.
  4. Given a shortcut would conflict with typing, when focus is inside a text input or the assistant composer, then the shortcut does not fire.
  5. Given I use a screen reader, when a shortcut performs an action, then the result is announced.
Implementation notes

Highest value in ListReviewQueue.vue (1094 lines, approve/reject per row plus approve-selected and approve-all) and HotelResultsTable.vue with its per-row, select-all-visible and virtual-selection modes. The assistant composer already binds Enter to send, which is the existing precedent to respect.

Edge cases & risks

Infinite-scroll tables move focus unpredictably when new pages load through the IntersectionObserver sentinel; focus must be pinned to the row, not the index.

ACCT-17

Use the results table and maps with a screen reader

Proposed
Data analystShould
As a Data analyst using assistive technology, I want the search results table and the maps to expose meaningful labels and text alternatives, so that I can evaluate hotels without sighted help.
Acceptance criteria
  1. Given the results table renders, when a screen reader reads a row, then the hotel name, location, star rating and user rating are announced as text, not as icon glyphs.
  2. Given the Data column renders its six availability icons (location, rating, features, reviews, images, rooms), when they are read, then each has an accessible name stating the attribute and whether it is available.
  3. Given the location picker map is used, when I navigate it with a keyboard, then a text alternative lets me set latitude, longitude and radius without dragging, and the live hotel count is announced politely when it settles.
  4. Given results load through infinite scroll, when a new page is appended, then an ARIA live region announces how many further results were added.
  5. Given an image fails to load in a gallery, when the fallback renders, then it carries a meaningful alternative text rather than an empty alt.
Implementation notes

Targets HotelsTable.vue / HotelResultsTable.vue, components/search/LocationPickerModal.vue (MapLibre, radius slider 1–200 km, area-scale slider 1–10×, debounced getNearbyHotels count with a "1000+" cap) and components/hotel/MapComponent.vue. PrimeVue 4 provides accessible primitives, but the icon-dense custom cells and canvas maps do not inherit them.

Edge cases & risks

Canvas-rendered maps are opaque to assistive technology by definition, so the text alternative is the feature, not a nicety. Chasing WCAG 2.2 AA without an automated check in CI means regressions land silently.

ACCT-18

Fix focus management and colour contrast

Proposed
Corporate travel buyerShould
As a Corporate travel buyer whose procurement process requires WCAG 2.2 AA, I want dialogs, drawers and data colouring to meet the standard, so that Hotel Universe can pass our accessibility review.
Acceptance criteria
  1. Given a $confirm.require dialog or an exports drawer opens, when it appears, then focus moves into it, is trapped while open, and returns to the triggering control on close.
  2. Given the onboarding tour is running, when a step is shown, then focus follows the highlighted element and Escape ends the tour, despite overlayClickBehavior: 'none'.
  3. Given the competitor price matrix renders its per-day cheapest-to-priciest heat gradient, when a cell is read, then price position is conveyed by text or pattern as well as by colour, and all text meets 4.5:1 contrast in both themes.
  4. Given rating gauges, confidence pills and status tags render, when they are compared, then meaning is never carried by hue alone.
  5. Given an automated accessibility check runs in CI, when a new violation is introduced, then the build reports it.
Implementation notes

Affects CompetitorPriceMatrix.vue (date × hotel matrix, heat gradient legend, weekend shading, ±% gap colouring), GaugesList.vue, HotelInfoFieldConsensus.vue confidence pills, the direct-pricing status icons, and the global <Toast> / <ConfirmDialog> in AppLayout.vue. The "Cosmic Calm" OKLCH ramps make contrast auditable programmatically.

Edge cases & risks

Heat maps are the hardest case: the gradient is the product's core price-intelligence visual, and making it colour-blind safe without losing its instant readability needs design work, not just a token swap.

ACCT-19

Use the portal in my own language

Proposed
Hotel ownerShould
As a Hotel owner in Spain, I want the portal in Spanish, so that my team can use it without translating rate terminology on the fly.
Acceptance criteria
  1. Given an i18n framework is installed, when any screen renders, then every user-visible string comes from a message catalogue rather than a template literal.
  2. Given I choose a language, when the portal reloads, then navigation, tables, empty states, error states, toasts and confirmation dialogs are all translated.
  3. Given a key is missing in my language, when the screen renders, then the English string is used as a fallback and the miss is logged.
  4. Given a language is right-to-left, when it is selected, then the layout mirrors correctly.
  5. Given server-produced copy is shown (for example "Upgrade to view. Contact sales at hello@ubio.ai" or "Cannot remove the last member of an organization"), when it renders, then it is either translated server-side by locale or mapped to a client-side key by error code.
Implementation notes

There is no i18n today: no vue-i18n, no $t(), no locale message files, and all copy is hard-coded English. The only locale-adjacent utilities are utils/locales.ts, utils/currencies.ts, utils/languageCodesMapToName.ts and the toLocaleDateString calls in utils/formatters.ts. Extraction is the bulk of the work — portal.css aside, copy is spread across every component, with only app/copy/toastTexts.ts centralised.

Edge cases & risks

Marketing copy on the landing page, the AI assistant's generated replies and the onboarding tour steps are three separate translation surfaces with different owners. Assistant output is model-generated and cannot be catalogued — it needs a language instruction instead.

ACCT-20

See price dates in the right timezone

Proposed
Revenue managerShould
As a Revenue manager, I want stay dates and refresh timestamps interpreted unambiguously, so that "cheapest on the 14th" means the same thing to me as it does to the API.
Acceptance criteria
  1. Given a stay date is displayed (check-in, direct-rate calendar day, matrix row), when it renders, then it is treated as a calendar date in the property's locale and never shifted by the browser timezone.
  2. Given a timestamp is displayed (price refreshed at, export created at, invitation sent at), when it renders, then it is shown in my chosen timezone with the zone indicated.
  3. Given I select a check-in date near midnight in a timezone behind or ahead of UTC, when the metasearch request is sent, then the date sent matches the date I picked.
  4. Given a direct-rate refresh batch completes, when "Prices refreshed" is shown, then the timestamp is unambiguous rather than a bare time.
  5. Given I set a timezone preference, when any timestamp renders, then it is used consistently across all screens.
Implementation notes

GET /metasearch takes checkIn as a date and nights as a count, so stay dates are calendar values, while GET /stats takes startDate/endDate as ms epoch — the two must not be formatted by the same helper. DirectPricesDetail.vue shows per-day Updated timestamps and a live refresh poll; ListExports.vue shows created dates and expiry.

Edge cases & risks

Date-versus-instant confusion is the classic source of off-by-one-day bugs in travel, and it lands directly on the product's core claim about which dates are cheapest. Export expiresAt (30 days) and signed URLs (15 minutes) are true instants and must stay so.

ACCT-21

Download a copy of my account data

Proposed
Trial userCould
As a Trial user, I want to download the personal data Hotel Universe holds about me, so that I can exercise my subject access rights before deciding to buy.
Acceptance criteria
  1. Given I request my data, when the job runs, then it assembles my users record, my org memberships, invitations I sent or received, tokens I created (names and metadata only, never secrets) and my access-log activity.
  2. Given the job completes, when the file is ready, then I am notified and can download it once through a time-limited signed link.
  3. Given the export contains other people's data, when it is assembled, then those fields are excluded or pseudonymised.
  4. Given I request a second export within the cool-off window, when I submit, then I am told when I can request again.
  5. Given the download link expires, when I open it, then I see an expired message with the option to request a fresh export.
Implementation notes

Reuses the export pipeline that already exists for lists: Readable.from(dbStream) → transform → toJsonArray → gzip → GCS, with getSignedDownloadUrl (v4, 15-minute validity) and the 200/202/410 download semantics. accessLogs is keyed by principalId, so per-user extraction is a direct query.

Edge cases & risks

Token secrets are stored only as SHA-256 hashes and must never be reconstructed or included. Access logs contain huId values that reveal the org's commercial interests, so this is arguably org data as much as personal data — legal review needed on what a member may take with them.

ACCT-22

Work properly on a phone or tablet

Proposed
Hotel ownerShould
As a Hotel owner, I want the price screens genuinely usable on a phone, so that I can answer "where am I being undercut this weekend, by which OTA, and by how much?" from anywhere.
Acceptance criteria
  1. Given I open the competitor price matrix on a phone, when it renders, then it adapts to a readable narrow layout (for example a per-day card list with the subject hotel pinned) rather than requiring horizontal scrolling of a wide grid.
  2. Given I open the direct-rates calendar on a phone, when it renders, then the date, availability and From price are legible without zooming, and row expansion works with touch.
  3. Given I use the location picker on touch, when I adjust the radius and area-scale sliders, then the targets are large enough to operate accurately and the live hotel count keeps updating.
  4. Given I use the AI assistant on a phone, when the on-screen keyboard opens, then the composer stays visible and the transcript scrolls correctly.
  5. Given I sign in on a phone for the first time, when the layout mounts, then I receive a mobile-appropriate introduction instead of the desktop tour being silently skipped (see ACCT-06).
Implementation notes

The heaviest screens are CompetitorPriceMatrix.vue (date rows × hotel columns with a market-median column), DirectPricesDetail.vue (180/365-day calendar with row expansion), HotelResultsTable.vue and LocationPickerModal.vue. The mobile drawer, collapsible selection rail and ScrollTop already exist as building blocks.

Edge cases & risks

Some of these screens are inherently two-dimensional; a naive responsive collapse loses the comparison that makes them valuable. Mobile also amplifies payload concerns — the matrix fetches months of price data at once, which is expensive on a phone connection.

Part 2

Finding hotels

SRCH 5 built7 partial12 proposed

Hotel search, filters & geospatial

Search is the front door of the portal: /search is where Home redirects, where the onboarding tour starts, and where every list, subscription and price query ultimately originates. Today it is a chip-based filter builder over GET /search (Atlas Search index atlas_custom_search_10 on canonicalData6) plus a MapLibre area picker backed by GET /search/nearby and GET /geocode. This epic covers the built filter catalogue and geospatial tooling, and proposes the sorting, relevance, saved-search and area-definition capabilities the current implementation does not have.

SRCH-01

Build a search from the editable filter chip bar

Built
OTA product managerMust
As an OTA product manager, I want to assemble a hotel search from editable filter chips, so that I can see exactly which constraints are active and change any one of them without rebuilding the whole query.
Acceptance criteria
  1. Given I am on /search in "Build with filters" mode, when I add a filter from FILTER_DEFS, then HotelFilterBar.vue renders it as a chip whose label comes from formatChipValue and the results reload via hotelManager.applyFilters.
  2. Given a chip is present, when I click it, then its editor opens in place and committing a new value rewrites the URL query string so the search is reproducible from the address bar.
  3. Given several chips are active, when I click "Clear all", then every filter is removed, the URL query is emptied and the screen returns to the "Add filters to start your search" empty state.
  4. Given results have loaded, when the request settles, then the bar shows <total> hotels · refreshed just now.
  5. Given a filter value is invalid for its definition (for example a non-numeric minReviews), when I try to commit it, then the chip stays in edit mode with an inline error and no request is issued.
Implementation notes

app/components/search/filterDefs.ts (FILTER_DEFS, activeFilterDefs, formatChipValue, clearFilterDef) drives HotelFilterBar.vue; HotelManager.queryToFilters() / applyFilters() own normalisation and URL sync. Telemetry: search_filter_applied (with filterType), filter_chip_removed, filters_cleared.

Edge cases & risks

Filters live only in the URL, so a very wide chip set produces long, ugly links; chip edits that race an in-flight request can show counts from the previous filter set until the newer response lands.

SRCH-02

Suggest filters as I type, respecting my tier

Built
Travel agency consultantShould
As a travel agency consultant, I want the filter bar to suggest matching filters as I type, so that I can find the right constraint without learning the full catalogue of field names.
Acceptance criteria
  1. Given I type into the filter bar, when the text matches a filter definition, then suggestFilterDefs(query, tier) returns ranked suggestions and they render as a picker list.
  2. Given my query matches no filter definition, when suggestions render, then the fallback option "Search name '<text>'" is offered so the text becomes a name filter.
  3. Given I am on a tier that does not license a filter, when suggestions are computed, then isFilterDefAvailableForTier excludes or disables it rather than letting me apply a filter that will be rejected.
  4. Given I pick a suggestion with the keyboard, when I press Enter, then the corresponding chip is created and focus moves to its value editor.
  5. Given the suggestion list is open and I press Escape, when the list closes, then no filter is added and the typed text is retained for editing.
Implementation notes

suggestFilterDefs(query, tier) and isFilterDefAvailableForTier in filterDefs.ts; tier comes from AccessManager.currentAccessInfo. The richContentSubscribed filter is the main tier-restricted entry (paid only).

Edge cases & risks

Suggestions are matched against hard-coded English labels with no i18n and no synonym table, so "wifi" or "pool" surfaces nothing useful; tier data is cached for 30 s in AccessManager, so a just-upgraded org can briefly still see a filter greyed out.

SRCH-03

Filter by name and place text

Partial
OTA supply managerMust
As an OTA supply manager, I want to filter hotels by name, city, state, postal code and brand, so that I can narrow a catalogue down to the properties in the market I am working on.
Acceptance criteria
  1. Given I set the name filter, when the search runs, then a scoring text clause on info.name is added to compound.must and results are ranked with that clause contributing to score.
  2. Given I set the city filter, when the search runs, then a non-scoring text clause on location.city is added to compound.filter so it narrows without reordering.
  3. Given I set state, postalCode or brand, when the search runs, then those chips are accepted by the filter bar and passed to GET /search.
  4. Given a city term that the source data spells differently (for example "Nice" versus a suburb name), when results render, then the Location column shows the "city boundaries" hint icon for rows whose city does not match my term.
  5. Given I supply only a postalCode and the live index does not support it, when the response comes back, then the UI must show an explicit "No results found. Please try different search parameters." state rather than silently dropping the filter.
Implementation notes

FILTER_DEFS defines name, city, state, postalCode and brand as text/token chips; the local API clone's Atlas index atlas_custom_search_10 is dynamic: false and indexes only info.name and location.city among these. Gap: state, postalCode and brand have portal chips with no corresponding indexed field in the clone's search implementation.

Edge cases & risks

Silently ignored filters are the worst failure mode here — a user believes they filtered by postcode and exports a list that was never constrained. location.state/province and brand are also sparsely populated across DS_NAMES, so even once indexed, recall will be uneven.

SRCH-04

Filter by country, stars, property type and languages

Partial
Corporate travel buyerMust
As a corporate travel buyer, I want to constrain a search by country, star rating, property type and languages spoken, so that I only see properties that can satisfy our travel policy.
Acceptance criteria
  1. Given I select one or more countries, when the search runs, then an in clause on location.countryCode (token, lowercase) is added to compound.filter using ISO alpha-2 codes from utils/countries.ts.
  2. Given I select star ratings, when the search runs, then an in clause on info.starRating is applied, and selecting 0 returns non-rated properties rather than excluding everything.
  3. Given I select property types, when the multiselect renders, then its options are exactly the PROPERTY_CODES set (hotel, hostel, motel, bed_and_breakfast, resort, vacation_rental, cottage, camp, unique_stay, lodging, other).
  4. Given I select languages spoken, when the search runs, then the codes from languageCodesMapToName.ts are sent as languagesSpoken.
  5. Given I select a country code that returns no properties, when results render, then the empty state appears and the chip stays visible so I can correct it in one click.
Implementation notes

Chips defined in filterDefs.ts; backend clauses in the search repo build compound.filter in clauses for stars[] and country[]. Gap: propertyType and languagesSpoken are portal filters with no indexed field in atlas_custom_search_10, which indexes only info.starRating and location.countryCode from this group.

Edge cases & risks

PROPERTY_CODES are mapped from roughly 80 raw source strings during ingest, so mis-mapped source values move a property into other and it disappears from a hotel filter. Star rating scale differs by source before canonicalisation, so 0 can mean "unrated" or "unknown".

SRCH-05

Match amenities with fuzzy feature search

Built
Travel agency consultantMust
As a travel agency consultant, I want to filter on amenities even when I do not spell them exactly as the data does, so that "swiming pool" or "airport shuttel" still finds the right hotels.
Acceptance criteria
  1. Given I add one or more features token chips, when the search runs, then one fuzzy text clause per feature is added to compound.must against features.title with maxEdits: 2.
  2. Given I add two features, when results return, then both clauses contribute to score so hotels matching both rank above hotels matching one.
  3. Given a feature term is a near-miss of a canonical title, when results return, then hotels carrying the canonical title are included.
  4. Given a feature term is far from any canonical title, when results return, then the result set is empty rather than being silently ignored.
  5. Given a hotel has an amenity recorded only in originalTitle and not in a mapped title, when I filter on the canonical term, then that hotel is not returned.
Implementation notes

Feature clauses are built as scoring must clauses in the Atlas compound; features.title is one of the indexed paths. Roughly 280 canonical FEATURE_TITLES exist in the API but are not yet enforced at ingest, so titles are whatever the Google/Booking/TripAdvisor feature maps produced.

Edge cases & risks

maxEdits: 2 over-matches short tokens ("spa" matches "sea", "bar"), producing confident-looking but wrong lists. Because feature clauses are must and scoring, adding features both narrows and reorders, which surprises users who expect filters to be non-destructive.

SRCH-06

Filter on which data a hotel actually has

Partial
Customer data engineerShould
As a customer data engineer, I want to restrict results to hotels that actually carry location, features, ratings, images, reviews, rooms or a direct website, so that the list I export is complete enough to ship into our catalogue.
Acceptance criteria
  1. Given I toggle hasLocation, hasFeatures, hasRating, hasImages or hasReviews, when the search runs, then an equals clause on the corresponding meta.*.available boolean is added to compound.must.
  2. Given I toggle several availability filters, when results return, then only hotels satisfying all of them are included.
  3. Given I toggle a filter on and off, when the search re-runs, then the clause is removed entirely rather than being sent as false.
  4. Given I toggle hasRooms or hasDirectWebsite, when the search runs, then the filter must be honoured server side and not silently dropped.
  5. Given every availability filter is enabled at once and no hotel qualifies, when results render, then the empty state explains that the combination is too strict.
Implementation notes

hasLocation/hasFeatures/hasRating/hasImages/hasReviews map to HotelMeta availability flags indexed as meta.*.available in atlas_custom_search_10. Gap: the portal also exposes hasRooms and hasDirectWebsite chips, which have no counterpart in the clone's shared filter set or search index.

Edge cases & risks

computeMeta makes images and reviews sticky — once available, always available — so a hotel whose images later 404 still passes hasImages. Availability says nothing about depth: hasReviews is true for a hotel with one review.

SRCH-07

Set quality and size thresholds

Partial
Revenue managerShould
As a revenue manager, I want to set a minimum guest rating, a minimum review count and a room-count range, so that my comparison set only contains properties of comparable standing and size.
Acceptance criteria
  1. Given I set minRating (0–5, step 0.1), when the search runs, then only hotels whose normalised overall rating meets or exceeds the threshold are returned.
  2. Given I set minReviews, when the search runs, then hotels with fewer reviews than the threshold are excluded.
  3. Given I set minRooms and maxRooms, when the search runs, then only hotels whose room inventory falls inside the range are returned.
  4. Given I set maxRooms lower than minRooms, when I commit the chip, then the range editor blocks the value with an inline error and no request is issued.
  5. Given a hotel has no rating at all, when minRating is set, then that hotel is excluded rather than treated as zero.
Implementation notes

Chips minRating, minReviews and the rooms range (minRooms/maxRooms) are defined in filterDefs.ts. The Atlas index carries rating.numberOfReviews and rating.overallRating.max. Gap: the clone's GET /search filter set has no minRating/minReviews/room-count parameters, and no room-count field is indexed at all.

Edge cases & risks

HotelRating.overallRating scale varies by source (Google 5, Booking 10) — a threshold applied before normalisation will silently mis-rank. Room counts come from HotelRoom.numberOfRooms per room type and are frequently absent, so a room-range filter will exclude large swathes of otherwise good data.

SRCH-08

Filter to subscribed and direct-pricing hotels

Partial
Organisation adminShould
As an organisation admin, I want to restrict a search to hotels we already subscribe to, or to hotels with direct pricing available, so that I can work within what we are already paying for instead of generating new charges.
Acceptance criteria
  1. Given my org is on the paid tier, when I open the filter suggestions, then the richContentSubscribed chip labelled "Subscribed hotels" is offered.
  2. Given I enable richContentSubscribed, when results return, then every row shows the subscribed badge in the Name column.
  3. Given I enable directPricingOnly, when results return, then only hotels linked to a Smart Feed / IBE property with direct pricing available are included.
  4. Given my org is on trial, when I try to use richContentSubscribed, then the filter is unavailable per isFilterDefAvailableForTier and the upgrade path is offered rather than an empty result set.
  5. Given the subscription state changes in another tab, when I re-run the search, then access.refreshAccessInfo(force) has been applied and the filter availability reflects the new tier.
Implementation notes

Both are boolean chips in FILTER_DEFS; tier gating flows through app/utils/accessTier.ts (showRichContentSubscribeOnSearch(tier), usesRichContentSubscriptionTabs(tier)) and AccessManager. Subscribed hotels are held per org (POST /orgs/:orgId/subscribed-hotels). Gap: neither filter exists in the local API clone's search parameters — both are live-only.

Edge cases & risks

Subscription is time-boxed (365 days, richContentExpiresAt), so a saved search built on richContentSubscribed quietly shrinks as subscriptions lapse. directPricingOnly depends on IBE linkage, much of which is still resolved by the ubio-internal manual matching screens.

SRCH-09

Pick a search area on the map

Built
Corporate travel buyerMust
As a corporate travel buyer, I want to draw my search area on a map instead of typing coordinates, so that I can say "within 5 km of this office" without leaving the product.
Acceptance criteria
  1. Given I open the location picker, when the map loads, then a MapLibre GL map renders with the https://tiles.openfreemap.org/styles/liberty style, a NavigationControl, zoom-around-centre behaviour and a fixed centre pin.
  2. Given I move the map or drag the radius slider between 1 and 200 km, when the interaction settles, then the radius-fill / radius-outline layers redraw and a debounced (500 ms) call to getNearbyHotels updates the live hotel count.
  3. Given more than a thousand hotels fall inside the area, when the count renders, then it displays as "1000+" rather than an exact number.
  4. Given I search a place name, when api.geocode(query) returns a result with a polygon, then boundary layers (boundary-fill / boundary-outline) replace the radius circle and the area-scale slider (1–10×) scales the polygon from its centroid.
  5. Given I confirm the area, when the modal closes, then it emits {lat, lon, radius, geometry: '<id>-<place_id>', geometryMultiplier} and those become a single map chip in the filter bar.
  6. Given the tile server or the geocoder is unreachable, when the modal opens or I search, then an error state is shown and the previously applied area is left untouched.
Implementation notes

components/search/LocationPickerModal.vue (maplibre-gl); counts via ApiManager.getNearbyHotels({lat, lon, radius, limit, geometry, geometryMultiplier})GET /search/nearby; place lookup via ApiManager.geocode(query)GET /geocode returning lat/lon/displayName/addressType/polygon/place_id. Server side, lat/lon/radius become a geoWithin.circle clause on the _loc 2dsphere/geo-indexed field, radius 1–10,000,000 m.

Edge cases & risks

Polygon scaling is sqrt-linear from the centroid, so scaling a concave or multi-part boundary (an island group, a city with exclaves) produces an area no user asked for. The debounced count is an estimate capped at 1000+, so it cannot be used to predict export size or subscription cost.

SRCH-10

Filter by a list of reference IDs

Partial
Customer data engineerMust
As a customer data engineer, I want to paste a set of hotel IDs of a chosen type and either include or exclude them, so that I can pivot straight from our own supply file into canonical records.
Acceptance criteria
  1. Given I add the hotelIds chip, when I choose a hotelIdType, then the options are huId plus the DS_NAMES set (GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia, AllTopHotels) and the 32 REF_EXTRA_OTA_NAMES slugs.
  2. Given hotelIdType is huId, when the search runs, then the backend short-circuits to $match {_id} and returns the exact records without scoring.
  3. Given hotelIdType is any other type, when the search runs, then an equals clause on refs.<type> is added to compound.filter.
  4. Given I set hotelIdFilterMode to exclude, when the search runs, then the listed IDs must be removed from the result set rather than being the only results.
  5. Given I paste IDs that do not resolve, when results render, then the count reflects only the resolved IDs and the unresolved ones are reported back to me rather than being silently dropped.
Implementation notes

Chip defined as hotelIds + hotelIdType + hotelIdFilterMode: include|exclude in filterDefs.ts; types/hotel.ts holds the ID-type list. Backend supports hotelIdType/hotelId over huId|googleHotelId|googlePlaceId|bookingHotelId|tripAdvisorHotelId|expediaHotelId. Gaps: the clone has no exclude mode, no multi-ID form, and no coverage for AllTopHotels or the extra OTA slugs; unmatched IDs are only surfaced via /stats on the CSV-upload path, not in search.

Edge cases & risks

refs.* are token-indexed and case-sensitive; an ID pasted with different casing or a stray space will not match. The huId short-circuit bypasses every other filter clause, so combining huId with a radius silently ignores the radius.

SRCH-11

Search inside an existing list

Partial
OTA supply managerShould
As an OTA supply manager, I want to run the full filter builder scoped to one of my lists, so that I can find "the 4-star properties in our Spain file" without exporting and re-uploading.
Acceptance criteria
  1. Given I am viewing a list, when I click "Search this list" in ListHeader.vue, then I am routed to /search?listId=<id> and hotelManager.setListScopeId() records the scope.
  2. Given a list scope is active, when I add any filter, then results are drawn only from hotels in that list and the scope is shown as a distinct, removable indicator.
  3. Given a list scope is active, when I save the selection or the search, then the resulting list contains only in-scope hotels.
  4. Given I remove the list scope, when the search re-runs, then it searches the full corpus and the listId query parameter is dropped from the URL.
  5. Given the referenced list is still processing or has status error, when I open /search?listId=, then an explanatory message is shown instead of an empty result set.
Implementation notes

HotelManager.listScopeId / setListScopeId(); entry point is the "Search this list" action in ListHeader.vue linking to /search?listId=. Gap: there is no listId parameter on GET /search in the API clone, so the scoping contract is live-only and undocumented.

Edge cases & risks

Scoping a dynamic list is ambiguous — the list is a stored searchQuery, so "search this list" is really a query intersection whose membership can change between page loads. Large lists risk pushing the scope into a huge $in, which interacts badly with Atlas Search scoring.

SRCH-12

Return nothing until a filter is set

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want an unfiltered search to return nothing instead of scanning the corpus, so that an empty query cannot become an accidental full-table export of a million hotels.
Acceptance criteria
  1. Given no filter clauses are produced, when GET /search builds its pipeline, then it emits $match {_id: 'impossible-id'} and returns an empty result set.
  2. Given no filters are active in the portal, when the search screen renders in filter mode, then the "Add filters to start your search" empty state is shown and no request is issued.
  3. Given no filters are active and no explicit intent is set, when the screen renders, then AiAssistant is shown inline as the default assistant mode rather than a blank table.
  4. Given a caller hits GET /search with only pageToken, when the request is handled, then it still resolves to the empty-clause path and returns no hotels rather than paging the whole corpus.
  5. Given an API consumer receives the empty response, when they inspect it, then total is 0 and nextPageToken is absent, so a client loop terminates.
Implementation notes

Implemented in the search pipeline builder: no compound clauses at all → $match {_id: 'impossible-id'}. Portal side, HotelsSearch.vue renders AiAssistant/AiSearch when there are no filters.

Edge cases & risks

The behaviour is a guard rail, not an error, so API consumers cannot distinguish "your filters matched nothing" from "your filters were all unrecognised and dropped" — both look like zero results. A documented 400 for a clause-free query would be safer, but would break the portal's current initial-load pattern.

SRCH-13

Sort results and keep cursors stable

Proposed
Data analystMust
As a data analyst, I want to choose how results are sorted and trust that paging through them does not repeat or skip rows, so that I can page a large result set into a spreadsheet without deduplicating afterwards.
Acceptance criteria
  1. Given I open the results header, when I choose a sort field (relevance, review count, guest rating, star rating, distance from the map centre, name), then the search re-runs with that sort and the choice is written to the URL query.
  2. Given a sort is active, when I page with nextPageToken, then the cursor encodes the sort so subsequent pages continue the same ordering.
  3. Given a sort field has ties, when pages are produced, then a stable tiebreaker on huId guarantees no row appears on two pages.
  4. Given underlying data changes between page one and page five, when I continue paging, then the API documents and enforces a snapshot window so the token either stays valid or fails with an explicit 409-style "cursor expired" error rather than returning inconsistent pages.
  5. Given I request a sort the index cannot serve, when the request is validated, then it is rejected with a clear message naming the supported sorts.
Implementation notes

Today there are no sort controls at all — the server always sorts rating.numberOfReviews: -1 and pages via Atlas searchAfter + searchSequenceToken (fetch 11, return 10). This would add a sort parameter to GET /search, extend the token payload, and add sort affordances to HotelResultsTable.vue.

Edge cases & risks

Sorting by distance is only meaningful when a map filter is present; sorting by rating without normalising the source scale (Google 5, Booking 10) would produce a nonsense order. Non-relevance sorts fight Atlas Search scoring and may need a $sort stage, which costs the searchAfter optimisation.

SRCH-14

Save filter presets and revisit search history

Proposed
OTA product managerShould
As an OTA product manager, I want to save named filter presets and see my recent searches, so that the twenty-chip query I built last week is one click away instead of being rebuilt from memory.
Acceptance criteria
  1. Given I have an active filter set, when I click "Save preset" and name it, then the normalised filter object is stored against my user and org and appears in a preset list.
  2. Given I open the preset list, when I select a preset, then its filters are applied through applyFilters and the URL query is rewritten to match.
  3. Given I have run searches recently, when I open the search history panel, then my last searches are listed with their chip summary and result count at the time they ran.
  4. Given a preset was saved by a colleague and shared with the org, when I open it, then it applies read-only unless I have permission to edit it.
  5. Given a preset references a filter my tier no longer licenses (for example richContentSubscribed), when I apply it, then the unavailable filter is flagged and skipped rather than causing a failed request.
Implementation notes

Presets are distinct from dynamic lists: a dynamic list materialises hotels via POST /search/save, whereas a preset stores only the filter object. History could be derived from the existing hotel_search_executed telemetry already sent to POST /portal-events, or stored explicitly alongside presets.

Edge cases & risks

Presets containing a map geometry reference a geocoder place_id that may change or disappear upstream. Org-shared presets need a permission model, and the platform currently has no per-user roles — every org member has identical scopes.

SRCH-15

Exclude hotels with negative filters

Proposed
Travel agency consultantShould
As a travel agency consultant, I want to say what a hotel must not be, so that I can express policies like "no hostels, not the Kensington branch, no properties without a cancellation policy".
Acceptance criteria
  1. Given any text or multiselect filter chip, when I toggle it to negative, then the chip renders with a clear "not" affordance and its clause moves to compound.mustNot.
  2. Given I negate a feature, when the search runs, then hotels carrying that feature are excluded even when they match every other clause.
  3. Given I negate a country while also including a country, when the search runs, then both clauses are honoured and the combination is explained in the chip bar.
  4. Given I negate every value of a filter, when results return, then the UI warns that the filter set is self-cancelling instead of showing a bare empty state.
  5. Given a negated clause targets a field with sparse data, when results return, then hotels missing that field are treated as not-excluded and this is stated in the UI.
Implementation notes

Builds on the existing Atlas compound construction, adding a mustNot bucket alongside must/filter; filterDefs.ts would gain a negatable flag and formatChipValue a negative rendering. The hotelIds chip already models this idea with hotelIdFilterMode: include|exclude.

Edge cases & risks

Negation over incomplete data is dangerous: excluding "no pet friendly" removes hotels that simply never had features scraped. Fuzzy feature matching with maxEdits: 2 in a mustNot clause will over-exclude far more visibly than it over-includes.

SRCH-16

Search around a point of interest or by travel time

Proposed
Corporate travel buyerShould
As a corporate travel buyer, I want to anchor a search on a named place and search by drive or walk time rather than straight-line radius, so that "within 20 minutes of the client office" is a query rather than a guess.
Acceptance criteria
  1. Given I type a point of interest (an office address, an airport, a station), when I select it, then GET /geocode resolves it and the map centres on it with the POI name shown on the chip.
  2. Given a POI anchor is set, when I choose a travel mode (drive, walk, transit) and a time budget, then an isochrone polygon is computed and used as the search geometry.
  3. Given an isochrone is active, when results return, then each row shows the estimated travel time to the anchor alongside the straight-line distance.
  4. Given the isochrone provider is unavailable or the time budget exceeds the supported maximum, when I apply it, then the search falls back to the equivalent radius circle with an explicit notice rather than failing.
  5. Given a hotel sits inside the isochrone but has no coordinates, when results return, then it is excluded and counted in a "hotels without location excluded" note.
Implementation notes

Extends the existing map filter, which already carries an arbitrary geometry string plus geometryMultiplier, and the geoWithin clause on _loc. Isochrones require a routing provider; the geocoder behind GET /geocode already returns polygons, so the polygon plumbing exists end to end.

Edge cases & risks

Isochrones are expensive and time-of-day dependent — caching them per POI/mode/budget is essential, and a cached rush-hour isochrone shown as an off-peak one is misleading. Transit isochrones have poor coverage outside major cities, which will look like a data bug to users.

SRCH-17

Draw a polygon or upload GeoJSON

Proposed
OTA supply managerCould
As an OTA supply manager, I want to draw a custom shape on the map or upload our own GeoJSON boundary, so that I can search our internal sales territories rather than administrative boundaries.
Acceptance criteria
  1. Given the location picker is open, when I switch to draw mode, then I can place vertices to form a polygon and the live nearby count updates as I close the shape.
  2. Given I upload a GeoJSON file, when it contains a valid Polygon or MultiPolygon, then it renders as the boundary layer and becomes the search geometry.
  3. Given the uploaded GeoJSON is invalid, self-intersecting, or exceeds a documented vertex limit, when it is parsed, then it is rejected with a message naming the problem and the current area is unchanged.
  4. Given a drawn or uploaded shape is active, when I save the search as a dynamic list, then the shape is persisted with the list so a later refresh searches the same area.
  5. Given a shape is active, when I also set a radius, then the UI makes clear which one wins rather than sending both.
Implementation notes

LocationPickerModal.vue already renders boundary-fill / boundary-outline layers for geocoded polygons and emits an opaque geometry identifier; this would extend geometry to carry (or reference) a stored shape and add a MapLibre draw control. Backend already uses _loc with a 2dsphere index, which supports $geoWithin on arbitrary polygons.

Edge cases & risks

Arbitrary polygons with many vertices will slow the geo clause and blow up saved-search payloads; a simplification step is needed. Shapes crossing the antimeridian or with reversed winding order are a classic source of "returns the whole planet" bugs.

SRCH-18

Filter by chain and brand hierarchy

Proposed
OTA supply managerShould
As an OTA supply manager, I want to filter by parent chain, brand and sub-brand as a hierarchy, so that I can pull "all Accor economy brands" without listing every brand name.
Acceptance criteria
  1. Given I open the chain filter, when the picker renders, then chains are shown as a tree of chain → brand → sub-brand with property counts at each node.
  2. Given I select a parent chain, when the search runs, then hotels belonging to every descendant brand are included.
  3. Given I select a brand and deselect one of its sub-brands, when the search runs, then that sub-brand's hotels are excluded.
  4. Given a hotel's brand is recorded only as free text by one source, when the hierarchy is built, then it is mapped to a canonical brand node or surfaced in an "unmapped brands" bucket rather than dropped.
  5. Given no brand data exists for a market, when I open the picker scoped to it, then an explicit "no brand coverage" state is shown instead of an empty tree.
Implementation notes

The portal already has a flat brand chip and HotelInfo.vue displays "Brand" and "Sub-brand". This would add a canonical chain reference dataset alongside the existing reference data (Countries.ts, IsoCodes.ts, FeaturesMapGoogle.ts) and index brand fields in atlas_custom_search_10.

Edge cases & risks

Brand attribution is the least consistent field across DS_NAMES, and the canonical merge takes the highest-priority source's value wholesale, so a stale GoogleHotels brand will beat a fresher Booking one. Chains rebrand and get acquired, so the hierarchy needs versioning or historical searches will drift.

SRCH-19

Filter by rate plan and cancellation policy

Proposed
Corporate travel buyerShould
As a corporate travel buyer, I want to filter to hotels that offer refundable rates, breakfast-included plans or a minimum free-cancellation window, so that the shortlist already satisfies our booking policy.
Acceptance criteria
  1. Given I set "refundable rates available", when the search runs, then only hotels with at least one refundable rate plan in the selected date window are returned.
  2. Given I set a minimum free-cancellation window in days, when results return, then each row shows the best cancellation deadline found.
  3. Given I set "breakfast included", when the search runs, then hotels whose rate plans carry the board type are returned and the matching plan is named on the result row.
  4. Given no dates are supplied, when I apply a rate-plan filter, then the UI requires a date range before running, because rate plans are date-dependent.
  5. Given a hotel has no direct pricing or metasearch coverage, when a rate-plan filter is active, then it is excluded and counted in a "no rate coverage" note rather than appearing with blank rate data.
Implementation notes

Would join search against the pricing surfaces rather than canonicalData6: GET /metasearch already returns rateType: cheapest|refundable and free-cancellation dates per offer, and Direct ARI exposes "per room, with full rate plans, LOS rules and cancellation policies" up to a 365-day lookahead. Gating would follow hasMetasearchAccess() / direct-pricing entitlement.

Edge cases & risks

Metasearch responses take 2–5 s per property and are rate-limited per principal (RATE_LIMIT_REQUESTS 100 / 60 s applied to GET /metasearch), so a live rate-aware filter over thousands of hotels is not feasible without a precomputed nightly index. Rate availability changes minute to minute, so results will be stale by the time a list is exported.

SRCH-20

Find hotels similar to one I already have

Proposed
Revenue managerShould
As a revenue manager, I want to start from one property and ask for hotels like it, so that I can build a comp set without hand-tuning a dozen filters.
Acceptance criteria
  1. Given I am on a hotel profile or a result row, when I click "Find similar hotels", then a search runs seeded from that hotel's stars, guest rating, property type, amenities and location.
  2. Given the similar-search runs, when results return, then each row shows a match percentage and the factors that drove it.
  3. Given I open the weighting editor, when I change the emphasis on stars, guest rating, property type, amenities or distance (including switching a factor off), then results recompute against the new weights.
  4. Given the seed hotel has sparse data (no features, no rating), when I run the search, then the UI warns that similarity will be weak and names the missing signals.
  5. Given no comparable properties exist within the search radius, when results return, then the empty state offers to widen the radius rather than showing a blank table.
Implementation notes

This generalises the existing competitor search: GET /hotels/:huId/competitors with {radius, limit, criteria}, surfaced by HotelLocation.vue with the CompetitorCriteria.vue weighting UI (stars, guest rating, property type, amenities, distance; levels including off; presets; reset) and 10 km default. The proposal is to promote that engine into the main search screen and let its output become a normal selectable result set.

Edge cases & risks

Competitor search is currently coupled to the metasearch/price-insights flows and its own access checks, so promoting it needs a clear entitlement story. Similarity over amenity counts rewards hotels with verbose source data rather than genuinely comparable ones.

SRCH-21

Improve natural-language filter parsing

Proposed
AI agent builderShould
As an AI agent builder, I want natural-language queries to map onto the real filter catalogue reliably and visibly, so that "4 star and up, walkable to the client office, refundable" produces filters I can inspect and correct.
Acceptance criteria
  1. Given I submit a natural-language query, when it is parsed, then the resulting filter set is shown as ordinary editable chips before results are fetched.
  2. Given part of my query cannot be mapped to any filter in FILTER_DEFS, when the chips render, then the unmapped fragment is shown explicitly as "not applied" rather than being dropped.
  3. Given the parser produces a filter my tier does not license, when chips render, then that chip is marked unavailable with an upgrade path instead of causing a failed search.
  4. Given I edit a parsed chip, when I re-run, then my edit is preserved and not overwritten by a re-parse of the original text.
  5. Given the parser returns low confidence for a numeric constraint (for example "cheap" or "big"), when chips render, then the UI asks me to pick a concrete threshold rather than guessing.
Implementation notes

Builds on the existing POST /search/ai path used by views/AiSearch.vue, which already applies filters and navigates to /search, and on the assistant's active-filter chips in views/AiAssistant.vue. The improvement is a strict mapping contract onto FILTER_DEFS plus an explicit unmapped-fragment channel in the response.

Edge cases & risks

A parser that silently invents filters is worse than one that refuses — the current failure mode (filters applied without explanation) already makes result counts hard to trust. Parsed geographies need to route through GET /geocode so that "near the client office" produces a real map filter rather than a city guess.

SRCH-22

Tune relevance with synonyms and explain why a hotel matched

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want a curated amenity synonym dictionary, configurable relevance weights and a per-result explanation of the match, so that I can make "wifi" find "Wireless internet" and answer "why is this hotel first?" without reading the Atlas pipeline.
Acceptance criteria
  1. Given a synonym mapping exists, when a user filters on any surface form ("wifi", "wi-fi", "wireless internet"), then the search expands it to the canonical features.title and matches all equivalent hotels, preferring synonym expansion over maxEdits: 2 fuzzy matching.
  2. Given a synonym set is edited, when the change is published, then the Atlas Search synonym mapping updates without a full reindex of canonicalData6, and terms outside the dictionary still fall back to the existing fuzzy behaviour.
  3. Given a search has run, when I expand a result's "why this matched" panel, then it lists the contributing clauses (name text, each feature clause, geo, availability equals) with their score contribution.
  4. Given relevance weights are configured, when a search runs, then the configured boosts are applied to the must clauses and the change is visible in the explanation panel.
  5. Given a result matched only on non-scoring filter clauses, or the query took the huId short-circuit path, when I open the explanation, then it states that scoring was bypassed rather than showing a fabricated relevance score.
  6. Given two synonym sets would merge distinct concepts (for example "spa" and "sauna"), when the set is saved, then validation blocks the merge and names the conflict.
Implementation notes

The API defines roughly 280 canonical FEATURE_TITLES that are not yet enforced, plus per-source maps (FeaturesMapGoogle.ts at 1166 lines, Booking and TripAdvisor maps) that the dictionary would sit beside. The current pipeline puts scoring clauses in compound.must (name text, geoWithin.circle, per-feature fuzzy text, meta equals) and non-scoring ones in compound.filter (stars in, countryCode in, city text, refs equals), then sorts by rating.numberOfReviews: -1 — so score is computed and then largely thrown away; exposing $search score metadata and a weights config makes that trade-off visible.

Edge cases & risks

Enforcing canonical titles or publishing synonyms retroactively changes what existing dynamic lists return on refresh — a silent membership change nobody asked for. Synonyms are language-specific and the platform has no i18n; explanations expose index internals, so the debug panel should be tier- or role-gated.

SRCH-23

Filter on our own custom fields

Proposed
Organisation adminCould
As an organisation admin, I want to attach our own fields to hotels in our lists and filter on them, so that internal attributes like contract status, negotiated-rate flag or account owner become searchable alongside canonical data.
Acceptance criteria
  1. Given I define a custom field for my org (string, number, boolean or enum), when it is saved, then it appears in the filter catalogue for my org only.
  2. Given I upload a CSV of hotel IDs plus custom-field values, when it is processed, then values are attached to the matching entries and unmatched rows are reported.
  3. Given a custom field exists, when I filter on it, then results are restricted to hotels in my org's data with that value and the chip renders like any other filter.
  4. Given a user from another org runs a search, when filter suggestions are computed, then my org's custom fields are never offered or returned.
  5. Given a custom field is deleted while a dynamic list depends on it, when that list refreshes, then the refresh fails loudly with a named cause instead of quietly returning everything.
Implementation notes

Custom values would live alongside listEntries (which already carries per-entry reference IDs) rather than polluting canonicalData6, with an org-scoped index for filtering. Ingest could reuse the existing CSV path (POST /lists/upload, csv-parse with columns: true, extra columns currently ignored) — the landing copy already promises "extra columns are ignored", which is exactly the data being discarded.

Edge cases & risks

Org-scoped fields must be strictly partitioned; the platform's cross-org guard today is a single 403 check on list reads, and POST /sources currently has no auth check at all, so a new write surface needs careful scoping. Custom-field filters cannot be served by the shared Atlas index without a per-org join, which will be the performance bottleneck.

SRCH-24

Search by pasting ten thousand IDs

Proposed
Customer data engineerShould
As a customer data engineer, I want to paste or upload up to 10,000 hotel IDs and get canonical matches back in one operation, so that I can reconcile a supply file without writing a paging loop.
Acceptance criteria
  1. Given I paste 10,000 IDs of a single hotelIdType, when I submit, then the request is accepted, chunked server side, and progress is reported while it resolves.
  2. Given the batch completes, when results render, then matched hotels are selectable as a normal result set and a downloadable report lists every unmatched input ID with a reason.
  3. Given I exceed the documented maximum, when I submit, then the request is rejected with a message naming the limit rather than timing out.
  4. Given the same ID appears twice in my input, when results return, then it is resolved once and the duplicate is reported in the summary.
  5. Given the operation is cancelled mid-flight, when I stop it, then partial results already resolved remain usable and nothing is written to a list unless I ask.
Implementation notes

Today the equivalent path is CSV upload to POST /lists/upload, which keeps a row only if it carries at least one of huId/googleHotelId/googlePlaceId/bookingHotelId/tripAdvisorHotelId/expediaHotelId, then runs matchRecordsToHotelsByIds as $lookup + $merge per id field in the fixed order googleHotelId → googlePlaceId → bookingHotelId → tripAdvisorHotelId → expediaHotelId (first match wins), leaving unmatched entries with huId: null. This story brings that power to search without forcing list creation first.

Edge cases & risks

The fixed field order means a row carrying conflicting IDs resolves to whichever field is checked first, which can be the wrong property; the report must show which field matched. At 10,000 IDs the response exceeds sensible page sizes, so the result must be a cursor or a job, not a single payload.

RSLT 8 built3 partial12 proposed

Results, selection & bulk actions

Everything downstream of search — lists, subscriptions, exports, metasearch comparisons — starts with picking rows out of HotelResultsTable. The table pages by infinite scroll over nextPageToken at ten rows a time, and selection spans per-row ticks, select-all-visible, a cancellable select-all-across-pages sweep, and a virtual mode for result sets above a thousand. This epic documents that machinery and proposes the comparison, layout, sharing and safety features it currently lacks.

RSLT-01

Read a hotel at a glance in the results table

Built
OTA product managerMust
As an OTA product manager, I want each result row to show the hotel's identity, location and quality signals, so that I can judge relevance without opening every profile.
Acceptance criteria
  1. Given results have loaded, when the table renders, then each row shows a selection checkbox, Name, Location, Star rating, User rating and a Data column.
  2. Given a hotel's name cell renders, when I click it, then I navigate to HotelDetails for that huId.
  3. Given my org subscribes to a hotel's rich content, when the row renders, then the subscribed badge icon appears next to the name.
  4. Given star rating or user rating is missing, when the row renders, then HotelRating shows an explicit empty treatment rather than a zero.
  5. Given the results request fails, when the table renders, then an error state is shown in place of the table and previously loaded rows are not left looking current.
Implementation notes

components/search/HotelsTable.vueHotelResultsTable.vue; ratings via the shared HotelRating component; rows are HotelPreview objects (huId, googlePlaceId?, preview{name, city, country, starRating, userRating}, meta{...available}) returned by GET /search.

Edge cases & risks

HotelPreview carries only preview fields, so anything richer requires a profile fetch that may return 402 on trial. Names come from the highest-priority source in _dsNames, so the table can show a name a user does not recognise from their own file.

RSLT-02

See which data each hotel has before selecting it

Built
Customer data engineerMust
As a customer data engineer, I want to see at a glance which data types a hotel has, so that I do not build a list of properties that will export as empty records.
Acceptance criteria
  1. Given a row renders, when the Data column is drawn, then six availability icons are shown for location, rating, features, reviews, images and rooms.
  2. Given a data type is unavailable, when the icon renders, then it is visually distinct from available and carries a tooltip naming the data type.
  3. Given I hover an icon, when the tooltip appears, then it names the data type in plain language rather than the field path.
  4. Given every icon is off for a row, when I select that hotel, then nothing prevents selection but the row is visually marked as sparse.
  5. Given the availability flags disagree with the profile (a sticky flag), when I open the hotel, then the profile's own empty states are authoritative.
Implementation notes

Icons are driven by HotelPreview.meta which mirrors HotelMeta availability flags derived by computeMeta during the canonical merge; the same six-icon pattern (minus one) appears as five icons in ListEntries.vue.

Edge cases & risks

computeMeta treats images and reviews as sticky — once true, always true — so the icons overstate current availability. The icons are the only pre-selection quality signal, so their accuracy directly affects subscription spend.

RSLT-03

Page results by infinite scroll

Built
Data analystMust
As a data analyst, I want results to load continuously as I scroll, so that I can scan a long result set without clicking through page numbers.
Acceptance criteria
  1. Given results are displayed, when I scroll the sentinel element into view, then an IntersectionObserver triggers hotelManager.loadMoreHotels() using the current nextPageToken.
  2. Given a page request is in flight, when I keep scrolling, then loadingMore prevents a duplicate request for the same token.
  3. Given the response contains no nextPageToken, when the last page renders, then the sentinel is removed and an end-of-results marker is shown.
  4. Given page size is ten, when a page loads, then exactly ten rows (or fewer on the last page) are appended in order.
  5. Given a page request fails, when the error is handled, then already-loaded rows remain and a retry affordance is offered instead of clearing the table.
Implementation notes

HotelResultsTable.vue uses an IntersectionObserver sentinel; HotelManager holds hotels, total, nextPageToken, loading, loadingMore. Server side, GET /search returns {total, nextPageToken, hotels} with page size 10, implemented as Atlas searchAfter + searchSequenceToken fetching 11 and returning 10.

Edge cases & risks

There is no way to jump to a position, so reaching row 4,000 means 400 sequential requests; the total itself is capped by a $searchMeta threshold of 1000 with an estimatedDocumentCount fallback, so the count above the sentinel can be an estimate. Browser memory grows unbounded on very long scrolls since rows are not virtualised.

RSLT-04

Select hotels row by row and by visible page

Built
OTA supply managerMust
As an OTA supply manager, I want to tick individual hotels or everything currently visible, so that I can assemble a list by hand when the filters cannot express what I want.
Acceptance criteria
  1. Given results are displayed, when I tick a row checkbox, then hotelSelectionManager.toggleHotel records it and the selection summary count increments.
  2. Given rows are loaded, when I use select-all-visible, then selectAll adds every currently loaded row and the header checkbox reflects the all/partial/none state.
  3. Given a hotel is selected and I scroll further, when new pages load, then syncVisibleHotels keeps the existing selection intact.
  4. Given I untick the header checkbox, when the action completes, then only the visible rows are deselected and selections made on earlier pages are preserved.
  5. Given a selected hotel disappears from the result set after a filter change, when the selection summary renders, then the count reflects the removal rather than reporting a phantom selection.
Implementation notes

HotelSelectionManager (toggleHotel, removeHotel, selectAll, isSelected, getSelectedIds, getSelectedHotels, getSelectedCount, syncVisibleHotels, clearAll).

Edge cases & risks

"Select all visible" means "all loaded so far", which grows as you scroll — the same control does different things at different scroll depths. Selection is keyed on huId, so a hotel that appears twice under different reference IDs is a single selection.

RSLT-05

Select every result across all pages, with a stop button

Built
Customer data engineerMust
As a customer data engineer, I want to select every hotel matching my filters, not just the ones on screen, so that I can turn a whole search into a list in one action.
Acceptance criteria
  1. Given a result set spans many pages, when I choose select-all-across-pages, then forEachHotelsPageForCurrentFilters walks every page for the current filters and accumulates ids.
  2. Given the sweep is running, when I watch the UI, then progress is shown and a "Stop" button is available.
  3. Given I click "Stop", when the abort signal fires, then paging halts promptly and the ids collected so far remain selected.
  4. Given I change any filter while a sweep is running, when the change is applied, then the sweep is aborted rather than continuing against stale filters.
  5. Given a page request fails mid-sweep, when the error surfaces, then the sweep stops with a message stating how many hotels were selected before the failure.
Implementation notes

HotelManager.forEachHotelsPageForCurrentFilters(onPage, {signal}) drives the paged fetch with an AbortSignal; the "Stop" affordance lives in the results table header alongside the bulk-select control.

Edge cases & risks

A sweep over a result set of tens of thousands issues thousands of GET /search calls at page size 10 — there is no rate limit on /search today (only GET /metasearch is limited), so this is a self-inflicted load risk. Results can shift under the cursor mid-sweep, producing a selection that never existed as a single consistent set.

RSLT-06

Work with very large selections virtually

Partial
OTA product managerShould
As an OTA product manager, I want selections above a thousand hotels to be handled as "everything matching these filters" rather than a literal id list, so that huge selections stay fast and do not break saving.
Acceptance criteria
  1. Given totalResults exceeds STATIC_SELECTION_LIMIT (1000), when I select all, then selectAllVirtual(scopeKey, total, visible) activates virtual selection keyed on the serialised filters.
  2. Given virtual selection is active, when the selection preview renders, then at most MAX_VIRTUAL_PREVIEW_HOTELS (250) hotels are materialised for display.
  3. Given virtual selection is active, when I save, then a toast produced by createVirtualSelectionSaveToast explains that the saved artefact is filter-based rather than a fixed set of ids.
  4. Given I change a filter while virtual selection is active, when the scope key changes, then the virtual selection is invalidated rather than silently re-pointed at a different result set.
  5. Given I deselect individual hotels while virtual selection is active, when the exclusion set is applied, then either the exclusions are honoured on save or the UI states clearly that they are not.
Implementation notes

app/utils/selection.ts defines STATIC_SELECTION_LIMIT = 1000, the scope key (serialised filters) and createVirtualSelectionSaveToast; HotelSelectionManager exposes selectAllVirtual and isVirtualSelectionActive, with MAX_VIRTUAL_PREVIEW_HOTELS = 250. Gap: virtual selection is a portal-side concept with no corresponding server contract beyond POST /search/save, so "virtual selection minus exclusions" has nowhere to be expressed.

Edge cases & risks

A virtual selection saved as a dynamic list is not a snapshot — refreshing it later returns whatever matches then, which is a materially different promise from "the 4,200 hotels I selected". The 250-hotel preview cap means users approve a bulk subscribe having seen 6% of what they are buying.

RSLT-07

Keep my selection while I navigate

Partial
Travel agency consultantShould
As a travel agency consultant, I want my selection to survive opening a hotel profile and coming back, so that I can check candidates individually without losing my shortlist.
Acceptance criteria
  1. Given I have selected hotels, when I navigate to a hotel profile and return to /search, then hydrate() restores the selection from sessionStorage.
  2. Given the selection changes, when persistChanges runs, then the ids are written to the hotel-universe:selected-hotels session key.
  3. Given I switch organisation, when the page reloads, then the selection is cleared so hotels from another org's context are not carried over.
  4. Given sessionStorage is unavailable or full, when persistence fails, then the failure is handled without breaking selection in memory.
  5. Given I close the browser tab, when I return later, then the selection is gone and the empty selection state is shown rather than a stale count.
Implementation notes

HotelSelectionManager.persistChanges() / hydrate() against the session key hotel-universe:selected-hotels; storage goes through the safe SessionStorageManager wrappers (loadJson/saveJson). Gap: only ids are persisted, so restoring a selection whose hotels are no longer in the visible result set gives a count without rows.

Edge cases & risks

Session scope means a browser crash or an accidental tab close discards work that could represent hours of curation. Persisting ids without the scope key makes it possible to restore a selection under a different filter set.

RSLT-08

Act on a selection from the selection rail

Built
OTA product managerMust
As an OTA product manager, I want one place that summarises my selection and offers the next actions, so that I can go from search to list, subscription or price comparison without hunting for buttons.
Acceptance criteria
  1. Given results are shown, when the selection rail renders, then it shows selected-versus-total counts and the active filter count.
  2. Given I have an active search, when I click "Save search as dynamic list", then hotelManager.saveList() posts to POST /search/save and I am routed to the new list.
  3. Given I have a selection, when I click "Save selection to static list", then the save form opens; when I click "View N selected", then the table filters to the selection and the control becomes "Show all results".
  4. Given my org is on a paid tier, when I click "Subscribe to N selected", then the rich-content subscribe modal opens showing hotels-to-add count, plan tier, usage lines and any over-limit charge warning.
  5. Given I am not on a paid tier, when the rail renders, then "Subscribe to N selected" is not offered and the upgrade path is shown instead.
  6. Given I click "Clear selection", when it completes, then the count returns to zero and the session-persisted ids are removed.
Implementation notes

Selection rail in HotelsSearch.vue with actions Save search as dynamic list, Save selection to static list, View N selected / Show all results, Subscribe to N selected (paid only, via RichContentSubscribeModal.vue and computeRichContentOverLimit), Compare metasearch, Clear selection, plus an "Assistant suggests" tip block; collapsible on mobile with a ResizeObserver-measured height. Telemetry: selection_drawer_opened, list_saved:dynamic, list_saved:selection_drawer.

Edge cases & risks

"Save search as dynamic list" and "Save selection to static list" sit next to each other and produce very different artefacts — one tracks the query, one freezes ids. Subscribing is a billable action reachable in two clicks from a bulk selection, so the confirmation copy is doing a lot of work.

RSLT-09

Save a selection into a new or existing list

Built
OTA supply managerMust
As an OTA supply manager, I want to save selected hotels either as a new list or appended to one I already have, so that I can build up a working set across several searches.
Acceptance criteria
  1. Given I open the save form with a selection, when it renders, then I can choose "new list" (with an optional name) or "append to existing list".
  2. Given I choose a new list without naming it, when I save, then saveListFromHotelIds creates it with the default name pattern Selected Hotels YYYY-MM-DD.
  3. Given I choose "append to existing", when the picker renders, then it loads my lists via getLists and appending posts the selected huIds to POST /lists/:id/entries.
  4. Given the save succeeds, when the toast appears, then it confirms the list name and offers a link to open it.
  5. Given the save fails or the target list is in error status, when the error surfaces, then a failure toast is shown and the selection is preserved so I can retry.
Implementation notes

components/search/SaveSelectionForm.vue; ApiManager.saveListFromHotelIds(hotelIds, name?)POST /lists/fromIds, appendListEntries(listId, huIds)POST /lists/:id/entries, getLists({createdBy, page}). Telemetry list_saved:selection_table / list_saved:selection_drawer.

Edge cases & risks

Appending to a dynamic list mixes manually chosen hotels with query-derived membership, and a later refresh or "Make static" makes the provenance ambiguous. There is no duplicate check on append, so the same hotel can be added repeatedly across sessions.

RSLT-10

Jump straight from a result to live prices

Built
Revenue managerShould
As a revenue manager, I want a one-click route from a search result to that hotel's OTA prices, so that I can check a candidate's rates without re-entering its ID.
Acceptance criteria
  1. Given a result row renders, when I click "Check prices", then I am taken to the metasearch screen with the hotel prefilled.
  2. Given the metasearch screen opens prefilled, when it loads, then the parameters are mirrored into the URL query so the link is shareable and auto-searches on deep link.
  3. Given I have a selection, when I click "Compare metasearch" in the selection rail, then the comparison is opened for the selected hotels.
  4. Given my org lacks metasearch access, when I click "Check prices", then the route guard sends me to Billing rather than to a failing search.
  5. Given the metasearch service returns no offers, when results render, then "No results found." is shown rather than an empty skeleton.
Implementation notes

"Check prices" lives in the Name column of HotelResultsTable.vue and targets views/Metasearch.vue, which mirrors its params into the URL and discards stale in-flight results via a request key. GET /metasearch is FULL-tier only (402 otherwise), accepts only huId|googleHotelId per METASEARCH_HOTEL_ID_TYPES, and is rate-limited per principal.

Edge cases & risks

Metasearch responses take 2–5 s and the rate limiter is 100 requests per 60 s per principal, so clicking "Check prices" down a result list will hit 429 with a Retry-After. Hotels without a googleHotelId ref cannot be priced at all, and the row gives no advance warning.

RSLT-11

Flag when a result sits outside the city I asked for

Partial
Travel agency consultantCould
As a travel agency consultant, I want to be told when a result's city does not literally match my city filter, so that I understand why a "Paris" search returns a hotel in a neighbouring commune.
Acceptance criteria
  1. Given a city filter is active, when a row's preview.city does not match the filter term, then the Location cell renders the "city boundaries" hint icon.
  2. Given I hover the hint icon, when the tooltip appears, then it explains that administrative city boundaries differ from colloquial usage.
  3. Given no city filter is active, when rows render, then the hint icon is never shown.
  4. Given the hint applies to most rows in a result set, when the table renders, then the explanation is surfaced once above the table rather than repeated on every row.
  5. Given the city field is missing for a hotel, when the row renders, then the Location cell shows an empty treatment and no misleading hint.
Implementation notes

Implemented as a per-row hint icon in the Location column of HotelResultsTable.vue, comparing the hotel's preview.city against the active city filter term. Gap: it is a string comparison, not a boundary test, so it flags spelling variants as boundary issues and misses genuine boundary cases where the strings happen to match.

Edge cases & risks

location.city comes from the highest-priority source in _dsNames.location, so the comparison is really "does this source's city string match your typing". Users may read the hint as a data-quality warning about the hotel rather than about their query.

RSLT-12

Choose my columns and row density

Proposed
Data analystShould
As a data analyst, I want to choose which columns appear, reorder them and switch to a compact row height, so that I can fit the fields I care about on one screen.
Acceptance criteria
  1. Given the results table is shown, when I open the column chooser, then I can toggle available columns (including ones not shown today such as country, review count, property type and hotel id) and reorder them by drag.
  2. Given I change columns, when the table re-renders, then my choice persists per user and per screen across reloads.
  3. Given I switch density to compact, when the table re-renders, then row height reduces without truncating the selection checkbox or the availability icons.
  4. Given I add a column whose data is not present in HotelPreview, when it renders, then it shows an explicit "not loaded" treatment rather than an empty cell that reads as missing data.
  5. Given I reset to defaults, when the action completes, then the original column set and density are restored.
Implementation notes

Extends HotelResultsTable.vue, whose columns are currently fixed (selection, Name, Location, Star rating, User rating, Data). Preferences would follow the existing client-persistence pattern (LocalStorageManager keys such as hotel-universe:search-mode, metasearch-filter-panels).

Edge cases & risks

Any column beyond HotelPreview's fields requires either a wider search projection or a second fetch, which conflicts with the ten-row page size. Per-user column state that is not org-shareable makes screenshots and support conversations harder to follow.

RSLT-13

See results on a map beside the table

Proposed
Corporate travel buyerShould
As a corporate travel buyer, I want a map view of my results next to the table, so that I can judge where the candidates actually sit relative to the office.
Acceptance criteria
  1. Given results are loaded, when I switch on the map pane, then hotels with coordinates are plotted and hovering a row highlights its marker and vice versa.
  2. Given many hotels fall in a small area, when the map renders, then markers are clustered with counts and expanding a cluster reveals its members.
  3. Given I pan or zoom the map, when I choose "search this area", then the map filter is updated and results reload for the new bounds.
  4. Given I select markers on the map, when the selection updates, then it is the same selection the table and selection rail use.
  5. Given hotels in the result set have no coordinates, when the map renders, then a count of unplotted hotels is shown so they are not silently invisible.
Implementation notes

Would reuse the MapLibre setup already present in LocationPickerModal.vue (openfreemap liberty style, NavigationControl) and components/hotel/MapComponent.vue (which already renders hotel plus competitor markers), driven by the same HotelManager result state.

Edge cases & risks

Only the first ten rows exist until you scroll, so a map of "results" is really a map of "loaded results" unless a separate bounded fetch is added — GET /search/nearby is capped and returns a count, not a full set. Clustering at low zoom over a large country will be dominated by data density, not relevance.

RSLT-14

Preview a hotel without leaving the results

Proposed
OTA product managerShould
As an OTA product manager, I want a quick-look panel for a result, so that I can check photos, key amenities and rating detail without losing my scroll position and selection.
Acceptance criteria
  1. Given a result row is shown, when I open quick look, then a drawer displays hero image, address, star and guest rating, top amenities and data availability without navigating away.
  2. Given the drawer is open, when I press the arrow keys, then I move to the previous or next result and the drawer content updates.
  3. Given the drawer is open, when I tick the select control inside it, then the main selection updates immediately.
  4. Given my org is on trial and has hit the profile limit, when I open quick look, then the preview-level fields are shown with the upgrade message instead of a 402 error page.
  5. Given the hotel has no images, when the drawer renders, then the existing empty-state copy is used rather than a broken image frame.
Implementation notes

Would reuse HotelDetailHero.vue, HotelRatingDetails.vue and the amenities card from components/hotel/, fetching via GET /hotels/:id (which returns {access: 'full'|'preview', hotel}) and falling back to utils/hotelPreviewToHotel.ts on preview access.

Edge cases & risks

Each quick look is a profile view and therefore counts against ACCESS_TIER_TRIAL_LIMIT (100 distinct hotels, all-time, counted from access logs) — arrow-key browsing could burn a trial in a minute. The drawer must not double-log hotels:view for a hotel already opened in the same session.

RSLT-15

Compare a few hotels side by side

Proposed
Revenue managerShould
As a revenue manager, I want to put two to four hotels side by side, so that I can see where they genuinely differ before choosing a comp set.
Acceptance criteria
  1. Given I have selected between two and four hotels, when I choose "Compare", then a comparison view shows them as columns with aligned attribute rows.
  2. Given the comparison renders, when I scan it, then differing values are visually emphasised and identical values are de-emphasised.
  3. Given I select more than four hotels, when I choose "Compare", then I am asked to narrow the set rather than being shown an unusable grid.
  4. Given a hotel is missing an attribute the others have, when the row renders, then it is marked as missing rather than blank.
  5. Given I have metasearch access, when the comparison renders, then an optional price row can be loaded on demand for the same dates across all columns.
Implementation notes

The "Field / Direct / OTAs" table in HotelDirectWebsiteOtaComparison (including its "Identical" rows and amenity shared/websiteOnly/otaOnly summary) is the closest existing pattern, and CompetitorPriceMatrix.vue already renders a hotel-column matrix. Comparison rows would draw on HotelInfo, features grouped by category, HotelRating and room counts.

Edge cases & risks

Attribute-level comparison exposes provenance conflicts loudly — two hotels described by different priority sources will look more different than they are. Loading prices for four hotels is four metasearch calls at 2–5 s each against a per-principal rate limit.

RSLT-16

Warn me about suspected duplicates in my results

Proposed
Customer data engineerShould
As a customer data engineer, I want to be told when two results look like the same property, so that entity-resolution misses do not become duplicates in my catalogue.
Acceptance criteria
  1. Given results contain two records above a similarity threshold, when the table renders, then they are grouped with a "possible duplicate" marker and a confidence score.
  2. Given I open the duplicate prompt, when it renders, then it shows the signals behind the score (name similarity, address similarity, postcode match, distance) side by side.
  3. Given I confirm the pair is the same property, when I submit, then the report is queued for Ubio data ops review and the pair is collapsed in my view.
  4. Given I confirm the pair is genuinely different (adjacent buildings of the same brand), when I submit, then the marker is suppressed for my org and the feedback is recorded.
  5. Given no duplicates are suspected, when results render, then no marker or extra row height is introduced.
Implementation notes

Reuses the confidence model already in the link package: nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accepted at ≥ 0.7, with Jaccard plus Levenshtein similarity, normalised postcode equality and Haversine distance. The presentation can follow ListReviewQueue.vue, which already shows per-signal confidence pills for overall/name/address/postalCode/geo.

Edge cases & risks

The known limitation that "123 Main St" and "124 Main St" are not well distinguished cuts both ways — false duplicate prompts erode trust faster than missed ones. Merging is destructive and must never be user-initiated without data-ops review.

RSLT-17

Export the results I am looking at

Proposed
Data analystShould
As a data analyst, I want to export the current result set straight from search, so that I do not have to create a list I will never use again just to get a CSV.
Acceptance criteria
  1. Given results are loaded, when I choose "Export results", then I can pick a format (CSV or JSON) and a scope (loaded rows, selection, or all matching results).
  2. Given I choose "all matching results", when the export is created, then it is produced asynchronously and appears in an exports panel with status and file size.
  3. Given the export is ready, when I download it, then a signed URL is used and its expiry is stated in the UI.
  4. Given my org is on trial, when I export, then the 100-hotel cap is applied and stated up front rather than after the file is produced.
  5. Given the export fails, when the error surfaces, then the generic failure message is shown together with a retry, and no partially written file is offered.
Implementation notes

Today exports exist only against a list: POST /lists/:listId/exports with type full|preview|reviews, streamed as Readable.from(dbStream) → transform → toJsonArray → gzip → GCS, expiresAt = now + 30 days, signed download URL valid 15 minutes, plus a source_data type in the portal gated on access.licensedSources. This story would let a search feed the same pipeline, using the repo's unbounded stream generator that already backs saved search.

Edge cases & risks

Exporting "all matching results" for a filter that matches hundreds of thousands of hotels is effectively a bulk data extraction — it needs the same tier gating and access logging (export:create:*) as list exports, or it becomes a licensing hole. The clone produces gzipped JSON only; CSV is live-portal behaviour and needs a defined column contract.

RSLT-18

Share a search by link

Proposed
Travel agency consultantShould
As a travel agency consultant, I want to send a colleague a short link to exactly the search I am looking at, so that we discuss the same result set instead of describing filters over chat.
Acceptance criteria
  1. Given a search is active, when I click "Share", then a short link is generated that resolves to the same filters, list scope and sort.
  2. Given a colleague in my org opens the link, when it resolves, then the filters are applied and the results reload live rather than being a stale snapshot.
  3. Given someone outside my org opens the link, when it resolves, then access is refused with a clear message rather than a redirect to Home.
  4. Given the search included a map geometry, when the link resolves, then the same area is restored including geometryMultiplier.
  5. Given a link references a filter the recipient's tier does not license, when it resolves, then that filter is flagged as unavailable and the rest of the search still runs.
Implementation notes

The portal already writes filters to the URL query via HotelManager.applyFilters and reads them back with queryToFilters, and the README requires that URLs address resources — so the substance here is a short-link store plus org-scoped resolution, not new filter plumbing.

Edge cases & risks

Long filter sets already make unwieldy URLs, so a share feature will be used heavily and become an access-control surface: short links must be resolved against org membership, not treated as capability tokens. Links must not embed the hotel-universe:selected-hotels selection, which is personal and session-scoped.

RSLT-19

Schedule a search and get the differences by email

Proposed
OTA supply managerCould
As an OTA supply manager, I want a saved search to run on a schedule and email me what changed, so that new supply in my markets reaches me without my checking.
Acceptance criteria
  1. Given a dynamic list exists, when I enable a schedule (daily or weekly), then the search re-runs on that cadence.
  2. Given a scheduled run finds hotels not previously in the list, when the run completes, then I receive an email summarising added and removed hotels with a link to the list.
  3. Given a run finds no changes, when it completes, then no email is sent unless I opted into empty-run notifications.
  4. Given a run fails, when the failure is recorded, then it is visible on the list and retried on the next cadence rather than silently disabling the schedule.
  5. Given I unsubscribe from a scheduled search, when the change saves, then no further emails are sent and the schedule is shown as off.
Implementation notes

Diff computation already exists: GET /lists/:id/diff powers ListDiffManager and the ListDiff.vue panel ("Found N hotels not in the list", Add selected / Add all). The gaps are scheduling and delivery — the platform sends exactly one email type today (sendInvitationEmail via SendGrid), and the landing page already advertises "push diffs to a webhook", which does not exist in the clone.

Edge cases & risks

Diffs on a filter-based list are sensitive to index and ingest changes, so a reindex or a synonym change could produce a huge false "new hotels" email. Email volume and unsubscribe handling are new operational surfaces with no existing precedent beyond invitations.

RSLT-20

Keep a selection across sessions and devices

Proposed
OTA product managerCould
As an OTA product manager, I want my working selection to survive closing the browser and to follow me to another machine, so that a long curation job is not lost to a tab close.
Acceptance criteria
  1. Given I have a selection, when I close the browser and sign in again, then the selection is restored with the filters it was made under.
  2. Given I sign in on another device, when I open search, then the same working selection is available.
  3. Given a hotel in my restored selection no longer matches the stored filters, when the selection renders, then it is still selectable and flagged as out of scope.
  4. Given I switch organisation, when the selection is resolved, then only selections belonging to the new org are shown.
  5. Given the server-side selection cannot be loaded, when the failure is handled, then the local session selection is used and a non-blocking notice is shown.
Implementation notes

Promotes the current session-only mechanism (HotelSelectionManager.persistChanges/hydrate against hotel-universe:selected-hotels) to a server-side working set keyed by user plus org, stored alongside the scope key from utils/selection.ts so restored selections carry their filter context.

Edge cases & risks

A durable selection is close to being an unnamed list, so it risks duplicating the list model — it should have an explicit lifetime and a one-click "make this a list". Cross-device sync needs conflict handling when both devices edit the selection.

RSLT-21

Undo destructive selection actions

Proposed
Travel agency consultantShould
As a travel agency consultant, I want to undo an accidental "Clear selection" or bulk deselect, so that one misclick does not destroy a curated shortlist.
Acceptance criteria
  1. Given I clear my selection, when the action completes, then a toast offers "Undo" for a defined window and restores the exact previous selection when used.
  2. Given I deselect all visible rows, when the action completes, then the same undo affordance is offered.
  3. Given I perform another selection action after clearing, when the undo window is still open, then undo restores the state immediately before the clear, not before the newer action.
  4. Given the undo window expires, when the toast dismisses, then the previous selection is discarded and no stale undo is offered later.
  5. Given a virtual selection was active, when I undo a clear, then the virtual scope key is restored and the UI states that the restored selection is filter-based.
Implementation notes

Builds on the existing global <Toast position="bottom-left"> in AppLayout.vue and HotelSelectionManager.clearAll; the undo buffer is a single previous snapshot of getSelectedIds() plus isVirtualSelectionActive state.

Edge cases & risks

Undo for a virtual selection can only restore the scope key, not a materialised set, so the restored state may resolve to different hotels. Undo must not extend to already-committed actions such as list creation or subscription, which have billing consequences.

RSLT-22

Navigate results from the keyboard

Proposed
Data analystCould
As a data analyst, I want to move through results, select and open them entirely from the keyboard, so that I can triage a long list quickly and accessibly.
Acceptance criteria
  1. Given the results table has focus, when I press the up and down arrows, then focus moves row by row and the focused row is clearly indicated.
  2. Given a row is focused, when I press Space, then it toggles selection; when I press Enter, then it opens the hotel profile.
  3. Given focus reaches the last loaded row, when I continue pressing down, then the next page loads and focus continues into it.
  4. Given I press Shift with the arrow keys, when the selection extends, then a contiguous range is selected.
  5. Given I use a screen reader, when I move through rows, then the row announces hotel name, location, ratings and which data types are available rather than reading raw icon markup.
Implementation notes

Applies to HotelResultsTable.vue; the infinite-scroll IntersectionObserver sentinel needs a keyboard-driven counterpart because focus movement does not necessarily scroll the sentinel into view. The six availability icons need accessible labels, which they currently rely on tooltips for.

Edge cases & risks

Keyboard paging plus select-all interactions can trigger the same bulk sweeps as the mouse path, so the abort control must also be reachable by keyboard. Focus management across appended pages is a common source of focus-loss bugs.

RSLT-23

Report a bad match from the results

Proposed
Customer data engineerShould
As a customer data engineer, I want to flag a result as wrong from the row itself, so that data quality problems reach Ubio with context instead of dying in an email thread.
Acceptance criteria
  1. Given a result row is shown, when I choose "Report an issue", then I can pick a reason (wrong hotel, duplicate, closed or non-existent, wrong location, wrong data) and add a note.
  2. Given I submit a report, when it is accepted, then the search query, filters, huId and the row's source references are attached automatically.
  3. Given I have reported a hotel, when it appears in later results, then my report status is visible on the row so I do not report it twice.
  4. Given the report submission fails, when the error surfaces, then my note is preserved and a retry is offered rather than losing the text.
  5. Given Ubio data ops resolves a report, when I next see the hotel, then the outcome is visible on the row or profile.
Implementation notes

Would extend the existing telemetry channel (portalTracking.trackPOST /portal-events) with a first-class feedback resource, and reuse the escalation pattern already built for the assistant (POST /ai/assistant/contact with message, transcript and page URL). Reports would join the same ops queue that reviews entity-resolution decisions.

Edge cases & risks

Without triage, a feedback button generates volume no one reads, which is worse than no button. Reports contain customer query context, so retention and cross-org visibility need to be explicit.

Part 3

Hotel content & data quality

HOTL 8 built4 partial12 proposed

Hotel profile: core content

The hotel profile is the canonical record a customer sees when they drill into a single property: GET /hotels/{huId} returns { access, hotel } and the portal renders it as a tabbed profile built from canonicalData6 — info, location, features, images, rooms, rating — plus AI-generated summaries and scraped direct-website data. Access is two-mode (HotelProfileAccess.FULL vs PREVIEW) and quota-bound, so most of this epic is as much about what a customer is allowed to see as about what the record contains. The proposed stories push the profile from "a snapshot of merged sources" towards "an auditable, correctable, richer content product".

HOTL-01

Retrieve a single hotel profile by Hotel Universe ID

Built
DeveloperMust
As a Developer, I want to fetch one canonical hotel record by its huId, so that I can render or store a complete property profile without stitching source feeds together myself.
Acceptance criteria
  1. Given a valid bearer token with the hotels:view scope, when I call GET /hotels/{huId}, then I receive { access: 'full' | 'preview', hotel } where hotel carries meta, refs, info, location, rating, features[], images[] and rooms.
  2. Given the call succeeds, when the response is written, then a row is appended to the accessLogs time-series collection with meta.action = 'hotels:view' and the huId, so the view counts against the organisation's quota exactly once.
  3. Given a huId that does not exist in canonicalData6, when I call the endpoint, then I receive a 404 rather than an empty 200 body.
  4. Given a token without hotels:view, when I call the endpoint, then I receive a 403 and no access-log row is written.
Implementation notes

GET /hotels/{huId} in the api package, guarded by HOTELS_VIEW; the portal wraps it as ApiManager.getHotel(hotelId) and stores the result on HotelDetailsManager.hotel / .hotelPreview via loadHotel(id). huId is the 10-character ^[A-Za-z0-9]+$ primary key of canonicalData6.

Edge cases & risks

The access-log row is written per view, so a customer polling the endpoint in a render loop burns trial quota against distinct hotels; repeat views of the same huId are deduplicated only because /stats counts distinct hotels per action, not requests.

HOTL-02

Present the profile as a tab set, padlocked on preview profiles

Built
OTA product managerMust
As an OTA product manager, I want the hotel profile split into predictable tabs I can link colleagues straight into, so that we can discuss a specific part of a property record without describing where to click.
Acceptance criteria
  1. Given a full profile, when HotelDetails.vue renders, then the tab set is overview, rooms (with room count), photos (with image count), reviews labelled "Ratings & Reviews" with a formatted count such as 1.2k, directwebsite labelled "Direct Website", metasearch, insights labelled "Price Insights" and directari labelled "Direct ARI".
  2. Given I open /hotels/:id?tab=rooms, when the page loads, then the Rooms tab is active without an extra click, and switching tabs rewrites ?tab= so the URL always addresses the resource I am looking at.
  3. Given the API returns access: 'preview', when the profile renders, then only the preview tab is interactive and overview, rooms, photos and reviews show a padlock icon with a tooltip carrying RICH_CONTENT_SUBSCRIBE_MESSAGE.
  4. Given a preview profile, when the tab set is built, then the directwebsite tab is hidden entirely rather than padlocked.
  5. Given I subscribe the hotel from the right rail, when the profile reloads, then the padlocks are removed without a full page refresh being required.
  6. Given a ?tab= value that is not in the tab set, when the page loads, then the profile falls back to the default tab instead of rendering an empty panel.
Implementation notes

views/HotelDetails.vue on the router entry /hotels/:id (name HotelDetails, title "…full profile"), tab state synced to ?tab= per the portal README rule that "URLs must address resources"; the padlock path is driven by HotelProfileAccess.PREVIEW with the subscribe CTA in HotelDetailRail.vue / RichContentSubscribeModal.vue and the copy constant in app/copy/toastTexts.ts.

Edge cases & risks

Metasearch, Price Insights and Direct ARI tabs embed whole views (Metasearch.vue, DirectPricesDetail.vue) and are gated separately by hasMetasearchAccess(), so a deep link into an inaccessible tab lands on an upgrade panel that reads as a broken link; preview payloads are padded into a Hotel shape by utils/hotelPreviewToHotel.ts, so a component that fails to handle undefined shows a confident-looking empty value instead of a lock.

HOTL-03

Return 402 Upgrade Required when the trial hotel-view limit is reached

Built
Trial userMust
As a Trial user, I want a clear, actionable message when I hit the trial profile limit, so that I know why the hotel will not open and who to talk to.
Acceptance criteria
  1. Given my organisation is on tier trial and has already viewed ACCESS_TIER_TRIAL_LIMIT (default 100) distinct hotels, when I request a hotel I have not viewed before, then the API returns 402 with the message "Upgrade to view. Contact sales at hello@ubio.ai".
  2. Given the 402, when the portal handles it, then HotelDetailsManager.upgradeRequired is set and the UpgradeRequired component renders with the lock legend, value proposition and "View Billing" / "Contact Sales to Upgrade" buttons.
  3. Given I am over the limit, when I re-open a hotel I have already viewed, then the profile loads normally because the quota counts distinct hotels from the access log, not requests.
  4. Given the 402 path is taken, when the request completes, then no hotels:view access-log row is written for the blocked hotel.
Implementation notes

Quota is computed by AccessTierService from distinct huId values in accessLogs for hotels:view, all-time; FULL tier is unlimited. Portal handling is in HotelDetailsManager.loadHotel(id).

Edge cases & risks

The limit is all-time and there is no reset or warning threshold, so a trial user can be one click from a hard stop with no prior notice; there is also no email at 80% or 100%, since sendInvitationEmail is the only transactional email that exists.

HOTL-04

Show a hotel hero with photo, name, stars and status badges

Built
Travel agency consultantShould
As a Travel agency consultant, I want the top of the profile to identify the property at a glance, so that I can confirm I am looking at the right hotel before I read the detail.
Acceptance criteria
  1. Given a hotel with images, when the profile opens, then HotelDetailHero.vue renders a hero photo, breadcrumbs, the hotel name and a star display.
  2. Given the hotel is rich-content subscribed by my organisation, when the hero renders, then a "Subscribed" badge is shown.
  3. Given the hotel has direct booking-engine rates available, when the hero renders, then a "Direct ARI available" badge is shown.
  4. Given the hotel has no images or the hero image URL fails to load, when the hero renders, then a placeholder is shown instead of a broken image element.
Implementation notes

components/hotel/HotelDetailHero.vue; the subscribed state comes from AccessManager.isRichContentSubscribed(huId) and the Direct ARI badge from HotelDetailsManager.directPricingStatus.

Edge cases & risks

Hero images are served through images.automation.cloud/img?hash=… and resized by the unauthenticated GET /img/resize app with cache-control commented out, so hero loads are neither cached nor rate-limited.

HOTL-05

Render core hotel identity and policy fields

Built
Customer data engineerMust
As a Customer data engineer, I want every core HotelInfo field rendered in one panel, so that I can eyeball the canonical record against what my own catalogue holds.
Acceptance criteria
  1. Given a full profile, when HotelInfo.vue renders, then it shows Name, Property type, Brand, Sub-brand, Address, Star rating, Languages spoken as tags, Phone as a tel: link, Website as host text plus an external link, Check-in from / Check-out until, and the free-text Description.
  2. Given info.propertyType.code, when it renders, then it is one of the PROPERTY_CODES (hotel, hostel, motel, bed_and_breakfast, resort, vacation_rental, cottage, camp, unique_stay, lodging, other) rather than a raw source string.
  3. Given languagesSpoken contains ISO language codes, when they render, then each is displayed as a human-readable name via utils/languageCodesMapToName.ts.
  4. Given a field is absent from the merged record, when the panel renders, then that row is omitted or shown as empty rather than rendering undefined or null.
  5. Given starRating is 0, when it renders, then it is presented as non-rated rather than as zero stars, consistent with the search filter where 0 means non-rated.
Implementation notes

components/hotel/HotelInfo.vue reading HotelInfo from canonicalData6 (name ≤ 1000 chars, website, description, propertyType {code,title}, starRating 0–5, languagesSpoken[], phoneNumber, checkIn.from, checkOut.until).

Edge cases & risks

Property type is mapped from roughly 80 raw source strings into 11 codes, so genuinely unusual stays collapse into other; brand and sub-brand are displayed but are not first-class indexed fields, so they cannot be filtered on in the same way as city or stars.

HOTL-06

Group amenities into the sixteen canonical categories

Partial
OTA product managerMust
As an OTA product manager, I want amenities grouped into stable categories with duplicates removed, so that my facilities filters mean the same thing across every property in my supply file.
Acceptance criteria
  1. Given a hotel with features, when the amenities card renders, then features are grouped by HotelFeature.category across the sixteen-value enum (Accessibility, Activities, Business & Events, Category, Family Friendly, Food & Drink, Hotel Services & Operations, Internet, Parking, Payments, Pet Friendly, Room Features, Safety & Hygiene, Transportation, Wellness, Other), each with its own icon.
  2. Given the same amenity was contributed by more than one source, when the card renders, then it appears once, not once per source.
  3. Given a feature carries tags such as Free, Extra charge or 24 Hour, when it renders, then the tag is shown alongside the title.
  4. Given a hotel with no features at all, when the amenities card renders, then an empty state is shown rather than an empty set of category headings.
Implementation notes

Amenities card in components/hotel/ (the older fieldset variant HotelFeatures.vue still exists); source titles are mapped by FeaturesMapGoogle.ts (1166 lines) plus the Booking and TripAdvisor feature maps, retaining originalTitle, originalCategory and dsName.

Edge cases & risks

Search matches features with a fuzzy text clause per feature (maxEdits 2) against features.title, so unnormalised titles degrade both the profile display and the features[] filter at the same time; enforcing FEATURE_TITLES later will be a breaking change for anyone who has stored our titles.

HOTL-07

Show structured location with coordinates and a map link

Built
Corporate travel buyerMust
As a Corporate travel buyer, I want the full structured address plus coordinates I can open in a map, so that I can verify a hotel is actually where our policy needs it to be.
Acceptance criteria
  1. Given a hotel with location data, when HotelLocation.vue renders, then street address, city, postal code, state or province, country and country code are shown as address lines.
  2. Given location.coordinates, when the panel renders, then latitude and longitude are displayed and are clickable through to Google Maps.
  3. Given the hotel has coordinates, when the location panel renders, then a MapLibre map (components/hotel/MapComponent.vue) plots the property.
  4. Given meta.location.available is false, when the panel renders, then an empty state is shown and no map is initialised.
Implementation notes

components/hotel/HotelLocation.vue reading HotelLocation {streetAddress, city, postalCode, countryCode, country, state, province, coordinates{lat,lon}, rawAddress}; _loc is the 2dsphere-indexed GeoJSON point used by geo search.

Edge cases & risks

Structured address components are produced by AddressSplittingService posting rawAddress to an external NodeScript endpoint; when that normalisation is poor the address lines and the coordinates can disagree, and nothing on the panel flags the conflict.

HOTL-08

Present room types in a comparable table

Built
Travel agency consultantShould
As a Travel agency consultant, I want room types laid out in a single table with size, occupancy and bed configuration, so that I can match a client's requirements without opening each room individually.
Acceptance criteria
  1. Given a hotel with rooms, when HotelRoomsDetails.vue renders, then it shows the total room count and the number of distinct room types above the table.
  2. Given each room type, when the row renders, then it shows a thumbnail, Name, Surface in m², Max Occupancy, Rooms, a Facilities count, Smoking as a Yes/No tag, Views as tags and Bed Configuration.
  3. Given a room has no thumbnail, when the row renders, then a placeholder is shown and the row layout does not shift.
  4. Given meta.rooms.available is false, when the Rooms tab is opened, then an empty state is shown and the tab count reads zero.
Implementation notes

components/hotel/HotelRoomsDetails.vue over HotelRoom {id, name, description, surfaceM2, maxOccupancy, numberOfRooms, smokingAllowed, bedConfiguration[], roomFacilities[], roomViewOptions[], extras{blocksForRoom[], roomDetails}}; Booking room data is scraped via mobile.roomList using a "best date" one-night USD probe.

Edge cases & risks

numberOfRooms comes from an availability probe on a single arbitrary date, so it reflects inventory offered that night rather than the property's true room count; the facilities column shows a count only, so two rooms with identical counts but different facilities look the same.

HOTL-09

Show grouped photo galleries with dedupe and broken-image handling

Partial
OTA product managerMust
As an OTA product manager, I want photos grouped by what they show and free of duplicates, so that I can drop them into a listing page without a manual clean-up pass.
Acceptance criteria
  1. Given a hotel with images, when the Photos tab renders, then galleries are grouped as "Hotel" plus one group per room type, each with its own count.
  2. Given the same image appears under more than one source or group, when the gallery renders, then it is shown once, per utils/imageDedupe.ts.
  3. Given an image URL fails to load, when the gallery renders, then that tile is removed or replaced rather than showing a broken-image icon.
  4. Given a hotel with no images, when the Photos tab is opened, then the empty state "There aren't any images for this hotel yet" is shown.
Implementation notes

components/hotel/HotelImages.vue; URLs are rewritten to https://images.automation.cloud/img?hash=<AES-256-CBC ciphertext>.<iv> at ingest and resized on demand by the img app (sharp, cover, cubic, JPEG q75).

Edge cases & risks

The AES key used to encode image URLs is hard-coded, and /img/resize has no auth and no cache headers; heavy gallery use is effectively an open image-proxy workload.

HOTL-10

Generate and poll an AI summary of the property

Partial
Revenue managerShould
As a Revenue manager, I want a short AI-written summary with highlight chips and a "Best for" line, so that I can position a property quickly without reading the full description and amenity list.
Acceptance criteria
  1. Given a full profile on a non-trial tier, when the Overview tab opens, then HotelAiSummary.vue requests GET /hotels/:id/summary and renders the summary text, highlight chips and a "Best for" section.
  2. Given the summary is still being generated, when the response status is generating, then a skeleton plus "Generating summary…" is shown and the client re-polls every 3 seconds.
  3. Given the summary is still not ready after MAX_POLLS = 20 attempts, when the poller gives up, then the summary card switches to an error state rather than spinning indefinitely.
  4. Given my organisation is on the trial tier, when the profile renders, then the AI summary card is hidden entirely.
  5. Given I request a regeneration, when the client calls GET /hotels/:id/summary?refresh=true, then a new summary is produced and replaces the cached one.
Implementation notes

HotelSummaryManager wrapping the generic GeneratingSummaryPoller<T> (3 s interval, MAX_POLLS = 20, statuses ready / generating / error, unit-tested in GeneratingSummaryPoller.test.ts); the same poller backs HotelReviewSummaryManager and DirectWebsiteInsightsManager.

Edge cases & risks

Twenty polls at three seconds is a hard 60-second ceiling; a cold generation for a very rich hotel can exceed it and surface as an error even though the summary lands moments later, and the client has no way to recover the completed result except a manual reload.

HOTL-11

Show scraped direct-website content alongside OTA values

Built
Hotel ownerShould
As a Hotel owner, I want to see what my own website says next to what the OTAs say about me, so that I can find and fix the places where my listing has drifted.
Acceptance criteria
  1. Given direct-website data has been scraped for the hotel, when the "Direct Website" tab opens, then HotelDirectWebsiteTab.vue shows Info (name, address, languages, phone, email, social links), Amenities groups, Location and Photos taken from the hotel's own site.
  2. Given both direct and OTA values exist for a field, when HotelDirectWebsiteOtaComparison renders, then a Field / Direct / OTAs table is shown, with rows where the two agree marked "Identical".
  3. Given amenities differ, when the comparison renders, then a shared / website-only / OTA-only summary is shown.
  4. Given AI insights are requested, when GET /hotels/:id/direct-website/insights is polled, then the insights card follows the same generating / ready / error states as the hotel summary.
  5. Given no direct-website scrape exists for the hotel, when the tab opens, then the empty state "No direct website data scraped yet" is shown rather than an error.
  6. Given the direct-website request fails, when the tab renders, then "Failed to load direct website data" is shown with the tab still navigable.
Implementation notes

ApiManager.getDirectWebsite(hotelId)GET /hotels/:id/direct-website and getDirectWebsiteInsights(hotelId, refresh)/direct-website/insights, surfaced through DirectWebsiteInsightsManager; social handles are parsed by utils/socialLinks.ts. The tab is hidden on preview profiles.

Edge cases & risks

Coverage is scrape-dependent and uneven, so for many hotels the flagship comparison table is simply absent; "Identical" is a string-level judgement, so trivially different formatting of the same address reads as a discrepancy.

HOTL-12

Export a single hotel record as JSON from the profile

Partial
Customer data engineerShould
As a Customer data engineer, I want to download one hotel's full record as a JSON file straight from the profile, so that I can inspect the exact shape of a record before I build an importer for it.
Acceptance criteria
  1. Given a hotel my organisation has rich-content subscribed, when I click export-record in the right rail, then a file named <huId>.json downloads containing the full profile payload.
  2. Given the hotel is not rich-content subscribed, when I click the action, then a warning toast explains the restriction and no file is produced.
  3. Given the export succeeds, when the file is opened, then it contains the same field set the profile renders, including refs, meta, info, location, rating, features, images and rooms.
  4. Given the profile is in preview access mode, when the rail renders, then the export action is disabled rather than failing after the click.
Implementation notes

HotelDetailRail.vue export-record action, gated on AccessManager.isRichContentSubscribed(huId); list-level equivalents go through POST /lists/{listId}/exports with type full | preview | reviews.

Edge cases & risks

Because the download is not metered, per-hotel export volume is invisible in GET /stats/ and in any usage-based commercial conversation; a customer could script the profile endpoint and reconstruct a bulk export outside the export quota.

HOTL-13

Keep a change history and diff timeline per hotel

Proposed
Customer data engineerShould
As a Customer data engineer, I want to see how a hotel record changed over time and when each field was last verified, so that I can explain to my own stakeholders why a value in our catalogue moved.
Acceptance criteria
  1. Given a hotel whose canonical record has been recomputed at least twice, when I open a History tab, then I see a reverse-chronological timeline of changes with the field group, old value, new value, contributing dsName and timestamp.
  2. Given a field has not changed since it was first written, when I view it on the profile, then a "last verified" timestamp is shown derived from the most recent successful scrape that confirmed the value.
  3. Given I call GET /hotels/{huId}/history?since=<iso>, when the range is valid, then I receive a paged change feed suitable for driving an incremental sync.
  4. Given a hotel with no recorded history because it predates the feature, when I open the tab, then an explanatory empty state is shown rather than an implied "nothing ever changed".
  5. Given a change was caused purely by a source being re-ranked rather than a value moving, when the timeline renders, then it is labelled as a provenance change, not a content change.
Implementation notes

Build on HotelDataUpdateService.combineData, which already knows the previous _dsNames[group] and the incoming values, by emitting a change document per field group into a time-series collection alongside accessLogs; "last verified" can come from scrapingStatuses updatedAt per source rather than needing new crawling.

Edge cases & risks

Naive per-field history over 1M+ hotels across seven field groups and five sources is a very large write volume; the design needs value-hashing and change-only writes, or it will dominate storage. Exposing history also exposes source churn that customers may read as instability.

HOTL-14

Compare a field's value across every contributing source side by side

Proposed
Data analystShould
As a Data analyst, I want to open any field and see what Google, Booking, TripAdvisor and Expedia each say, so that I can judge for myself whether the canonical pick is the right one.
Acceptance criteria
  1. Given a hotel with more than one contributing source, when I click a field in HotelInfo.vue, then a panel shows each source's value for that field with its dsName and crawl timestamp.
  2. Given the canonical value came from the highest-priority source, when the panel renders, then that row is marked as the winning value and the priority rule that selected it is stated.
  3. Given the sources disagree, when the panel renders, then the disagreement is shown explicitly rather than only summarised as a "Low confidence" badge.
  4. Given only one source contributed, when I open the panel, then it says so rather than rendering an empty comparison.
  5. Given a source's value exists but was suppressed by field-group priority, when the panel renders, then it is shown as suppressed, not as missing.
Implementation notes

All the raw values already exist in sourceHotelData5 keyed by {dsName, dsHotelId} and linked by huId; this is a read-only endpoint such as GET /hotels/{huId}/sources?field=info.name plus a popover in HotelInfo.vue, reusing the consensus data structure that already backs HotelInfoFieldConsensus.vue.

Edge cases & risks

Showing raw OTA values verbatim raises redistribution questions — some sources' terms allow us to derive canonical values but not to republish their originals, so this needs to respect AccessManager.licensedSources in the same way the source_data export does.

HOTL-15

Let customers suggest a correction to a hotel record

Proposed
Hotel ownerShould
As a Hotel owner, I want to flag a wrong value on my own property and propose the right one, so that the record that OTAs and agents consume stops being wrong.
Acceptance criteria
  1. Given any field on a full profile, when I click "Suggest a correction", then I can submit a proposed value with an optional evidence URL and free-text note.
  2. Given I submit a correction, when it is accepted, then I receive a reference and the field shows a "Correction pending" marker on my organisation's view of the profile.
  3. Given a Ubio data ops engineer reviews the correction, when they accept it, then the value is written as a manual-override source that outranks scraped sources for that field group and is recorded in the change timeline.
  4. Given a correction is rejected, when the decision is made, then the submitter sees the reason and the field marker is cleared.
  5. Given the next scrape returns the old value, when the canonical record is recomputed, then the accepted manual override is not overwritten.
Implementation notes

Add a Manual entry at the top of DS_NAMES so the existing combineData priority walk enforces overrides for free, plus a review queue modelled on ListReviewQueue.vue (approve / reject per row, per-signal detail in an expansion panel).

Edge cases & risks

A hotel owner asserting values about their own property is both the best and the worst source — self-reported star ratings and amenity lists are a known inflation risk, so overrides need an audit trail and probably a per-field allow-list. Unbounded manual overrides also fork the record away from what re-scraping can maintain.

HOTL-16

Score image quality and choose the hero photo automatically

Proposed
OTA product managerShould
As an OTA product manager, I want photos ranked by quality with a sensible hero picked automatically, so that the first image a traveller sees is never a car park or a scanned menu.
Acceptance criteria
  1. Given a hotel's images, when the gallery is built, then each image carries a quality score derived from resolution, aspect ratio, compression ratio and duplicate-cluster size.
  2. Given scored images, when the profile loads, then the hero is the highest-scoring exterior or lobby image rather than the first image in the array.
  3. Given I call the API, when I request the hotel, then each image includes its score and a suggested role (hero, exterior, room, amenity, other).
  4. Given an image scores below a configurable floor, when the gallery renders, then it is excluded from the default view but still reachable via "show all".
  5. Given no image clears the floor, when the profile renders, then the placeholder hero is used rather than promoting a low-quality image by default.
Implementation notes

SourceImageData already stores width, height, format, fileSize, pixels, aspectRatio, compressionRatio and estimatedQuality per source image, so scoring is mostly a matter of surfacing and combining fields that are already collected; classification into roles would need a vision pass at ingest.

Edge cases & risks

This depends on the encodeImageUrls dimension-loss bug being fixed first (HOTL-09) — scoring on canonical records that have lost width/height would silently fall back to defaults and rank everything equally.

HOTL-17

Map canonical room types onto a customer's own taxonomy

Proposed
Customer data engineerCould
As a Customer data engineer, I want to define my own room-type taxonomy and have canonical rooms mapped into it, so that hotel content lands in my system already speaking my vocabulary.
Acceptance criteria
  1. Given I upload a taxonomy of my room categories, when it is saved against my organisation, then it is versioned and visible in Settings.
  2. Given a hotel profile, when rooms are returned, then each room carries my mapped category alongside the canonical name, with a mapping confidence.
  3. Given a room cannot be mapped above threshold, when the record is returned, then it is flagged unmapped rather than forced into a default bucket.
  4. Given I export a list with type full, when the export is produced, then the mapped categories are included as additional fields.
  5. Given I revise my taxonomy, when I re-run mapping, then previously accepted manual mappings are preserved.
Implementation notes

Reuses the scoring and review-queue pattern already proven in the CSV matching flow (matchRecordsToHotelsByIds, ListReviewQueue.vue, confidence thresholds, approve/reject) but applied to HotelRoom.name plus bedConfiguration and maxOccupancy as signals.

Edge cases & risks

Room names are the least normalised part of the record (they come straight from scraped room lists), so mapping quality will be materially worse than hotel-level matching; per-organisation mapping tables also multiply export cost since exports currently stream a single canonical shape.

HOTL-18

Add floor plans, video and virtual tours to the media set

Proposed
Corporate travel buyerCould
As a Corporate travel buyer, I want floor plans and walkthrough media as first-class assets on the profile, so that I can assess a property for an event or a long stay without a site visit.
Acceptance criteria
  1. Given a hotel with non-photo media, when the Photos tab renders, then a Media section lists floor plans, video URLs and virtual-tour links as separate groups.
  2. Given a floor plan asset, when it is opened, then it renders at full resolution with zoom rather than being downscaled by the image proxy.
  3. Given a video or tour is hosted by a third party, when it renders, then it is shown as an outbound link with the host named, not embedded without consent.
  4. Given a hotel has no media of these types, when the tab renders, then the Media section is omitted entirely rather than showing three empty groups.
Implementation notes

Extend HotelImage into a media union with a kind discriminator so images[] stays backward compatible, and bypass the img resize path for floor plans, where cover-cropping to a thumbnail destroys the asset.

Edge cases & risks

The current image pipeline assumes cover-resize JPEG output; floor plans and PDFs would need a separate storage and serving path. Third-party video links rot quickly and there is no link-checking job today.

HOTL-19

Capture meeting-room and food-and-beverage inventory

Proposed
Corporate travel buyerCould
As a Corporate travel buyer, I want structured meeting-space and on-site dining inventory, so that I can shortlist properties for a corporate programme or an event on facts rather than marketing prose.
Acceptance criteria
  1. Given a hotel with meeting facilities, when the profile renders, then a Meetings section lists each space with name, area in m², capacity by layout and whether it has natural light.
  2. Given a hotel with on-site outlets, when the profile renders, then an F&B section lists each outlet with name, type, cuisine and opening hours.
  3. Given I search with a meetingCapacity filter, when results return, then only hotels with a space at or above that capacity are included.
  4. Given a hotel where only the boolean amenity "Meeting rooms" is known, when the section renders, then it states that detailed inventory is not available rather than showing a zero-capacity space.
Implementation notes

Today this exists only as a Business & Events feature category with boolean-ish titles; structured capture would need a new sub-document on CanonicalData plus Booking and TripAdvisor extraction, and a new Atlas Search field to make capacity filterable.

Edge cases & risks

Meeting capacity is quoted inconsistently across sources (theatre vs banquet vs classroom), so a single "capacity" number would be misleading; coverage would be very thin outside chain properties, which risks a filter that silently excludes good hotels.

HOTL-20

Present accessibility as structured, verifiable detail

Proposed
Travel agency consultantShould
As a Travel agency consultant, I want accessibility described specifically rather than as a single amenity chip, so that I can book a room a wheelchair user can actually use.
Acceptance criteria
  1. Given a hotel with accessibility information, when the profile renders, then an Accessibility panel lists specific attributes (step-free entrance, lift, roll-in shower, grab rails, visual alarms, accessible parking, hearing loop) each with present / absent / unknown.
  2. Given an attribute is unknown, when the panel renders, then it is explicitly marked unknown rather than defaulted to absent.
  3. Given accessible room types exist, when the Rooms table renders, then those rooms are tagged and can be filtered to.
  4. Given a source only provides the coarse "Accessibility" category, when the panel renders, then it states that only category-level information is available and links to the hotel's own website.
Implementation notes

The Accessibility value already exists in the sixteen-category HotelFeature enum and TripAdvisor PoiAmenities returns granular items, so this is largely a mapping and presentation problem on top of the existing feature pipeline plus enforcement of the canonical FEATURE_TITLES.

Edge cases & risks

Publishing accessibility claims we scraped from OTAs creates real-world harm and legal exposure if wrong; present / absent / unknown must be honest, and "unknown" will be the most common answer for a long time.

HOTL-21

Model pet, parking and EV-charging rules as structured policies

Proposed
OTA product managerCould
As an OTA product manager, I want pet, parking and EV-charging rules as structured policies rather than free-text chips, so that I can display accurate prices and restrictions without a human reading each hotel.
Acceptance criteria
  1. Given a hotel that accepts pets, when the profile renders, then a Pets policy shows allowed, fee amount and currency, per-night vs per-stay, weight or size limits and any restricted areas.
  2. Given a hotel with parking, when the profile renders, then a Parking policy shows on-site vs nearby, public vs private, reservation required, and price with unit.
  3. Given a hotel with EV charging, when the profile renders, then the connector types, number of bays and whether charging is free or metered are shown.
  4. Given only the boolean feature is known (for example the Pet Friendly category with a Extra charge tag), when the panel renders, then the structured fields are marked unknown rather than guessed.
  5. Given a policy is returned by the API, when it is serialised, then units and currency are explicit fields, never embedded in a display string.
Implementation notes

HotelFeature.tags already encodes Free / Extra charge / On-site / Nearby / Public / Private, which is the seed of the structure; the Booking scraper's getPolicies call already returns policy text that is currently only kept in sourceHotelData5.policies.

Edge cases & risks

Fees change more often than the 30-day scrape threshold, so a stale price shown as structured data reads as authoritative in a way free text does not — these fields need per-field freshness (HOTL-13) before they are safe to publish.

HOTL-22

Serve hotel descriptions in multiple languages

Proposed
OTA product managerCould
As an OTA product manager, I want the hotel description available in the languages my site supports, so that I do not have to run my own translation pipeline over your content.
Acceptance criteria
  1. Given a hotel record, when I call GET /hotels/{huId}?lang=de, then info.description is returned in German where available with a descriptionLanguage field stating what I actually got.
  2. Given the requested language is unavailable, when the record is returned, then the default English description is returned and descriptionLanguage reflects the fallback rather than silently lying.
  3. Given a translated description exists, when the record is returned, then it is marked as machine-translated or source-supplied.
  4. Given an export of type full, when a language is requested, then the export carries the requested language consistently for every hotel in the list.
Implementation notes

The Booking scraper already calls getHotelDescriptionTranslations, so multi-language descriptions are partly collected and discarded at merge; storing them as a language-keyed map on HotelInfo is the smaller half of the work. Note the portal itself has no i18n at all, so this is an API-first capability.

Edge cases & risks

Language coverage will be source-skewed towards Booking, so a lang= parameter that works for Booking-covered hotels and falls back for Google-only ones produces an inconsistent catalogue; per-language descriptions also multiply export size.

HOTL-23

Give brands and chains their own navigable hierarchy

Proposed
OTA supply managerCould
As an OTA supply manager, I want brand and sub-brand to be navigable entities rather than free-text fields, so that I can pull every property in a chain and reason about supply at portfolio level.
Acceptance criteria
  1. Given a hotel with a brand, when I click the brand on HotelInfo.vue, then I land on a search pre-filtered to that brand.
  2. Given a chain with sub-brands, when I open the brand page, then I see the sub-brands beneath it with hotel counts per country.
  3. Given I search with brand, when results return, then matching is against a canonical brand entity rather than a fuzzy string, so "Hilton Garden Inn" does not also return "Hilton".
  4. Given a hotel is independent, when the profile renders, then no brand link is shown rather than linking to an empty brand page.
Implementation notes

brand is already a search filter def in filterDefs.ts and is displayed by HotelInfo.vue, but there is no brand collection and brand is not in the atlas_custom_search_10 indexed field list — this needs a canonical brand entity, a resolution pass over existing values and a new indexed field.

Edge cases & risks

Brand strings vary wildly by source and franchise agreements change ownership without changing signage, so a brand entity will need the same manual-override and dispute machinery as the rest of the record; getting it wrong is commercially sensitive for the brands themselves.

HOTL-24

Produce a printable hotel factsheet

Proposed
Travel agency consultantCould
As a Travel agency consultant, I want a one-click PDF factsheet for a property, so that I can send a client something readable without screenshotting the portal.
Acceptance criteria
  1. Given a full profile, when I choose "Download factsheet", then a PDF is produced containing the hero image, identity fields, address and map, top amenities, room table and rating summary.
  2. Given the hotel is not rich-content subscribed, when I choose the action, then it is blocked with the same warning as the JSON record export.
  3. Given the factsheet is generated, when it renders, then it carries the generation date and the Hotel Universe ID so a recipient can trace it back.
  4. Given generation fails, when the request completes, then an error toast is shown and no partial file is downloaded.
Implementation notes

Sits next to the existing export-record action in HotelDetailRail.vue and would reuse the export pipeline pattern (async job, GCS upload, signed download URL) rather than rendering client-side, so large hotels do not block the browser.

Edge cases & risks

A PDF that embeds OTA-sourced photos and review scores is a redistribution surface; attribution and licensedSources checks matter more here than for JSON, because the artefact leaves the customer's systems.

REVW 8 built2 partial13 proposed

Reviews, ratings & sentiment

Reviews are the highest-tier content the platform serves: GET /hotels/{huId}/reviews requires the reviews:view scope and is FULL-tier only, returning 402 on trial. Underneath sit canonicalReviewData1 for individual reviews, HotelRating for aggregate scores and distributions, reviewMentionRatings for per-aspect sentiment, and an AI review summary served through the same generating-poller as the hotel summary. The hard problem in this epic is that every source rates on a different scale and describes different aspects, so most of the proposed work is about making review data comparable, filterable and defensible rather than merely displayable.

REVW-01

Serve individual hotel reviews to FULL-tier consumers

Built
DeveloperMust
As a Developer, I want to fetch the review corpus for a hotel by ID, so that I can surface guest feedback in my own product without scraping OTAs.
Acceptance criteria
  1. Given a token with the reviews:view scope on a FULL-tier organisation, when I call GET /hotels/{huId}/reviews, then I receive the hotel's reviews with source, reviewDate, overallRating, original{languageCode, language, text} and translation where present.
  2. Given my organisation is on the trial tier, when I call the endpoint, then I receive 402 because allowsReviews is FULL-only, and no review text is returned.
  3. Given the call succeeds, when it completes, then an access-log row with meta.action = 'reviews:view' is written for that huId.
  4. Given the hotel has no review document in canonicalReviewData1, when I call the endpoint, then I receive an empty review array with a 200, not a 404.
Implementation notes

GET /hotels/{huId}/reviews guarded by REVIEWS_VIEW; reviews live in their own collection canonicalReviewData1 {huId (_id), _dsNames.reviews, reviews[]}, separate from canonicalData6. Portal access is via HotelReviewsManager.loadReviews(hotelId).

Edge cases & risks

Reviews are stored as a single array on one document per hotel, so a hotel with tens of thousands of reviews produces an unbounded response with no pagination parameters — this is the sharpest scale risk in the epic.

REVW-02

Render individual reviews with original text and translation

Built
Revenue managerMust
As a Revenue manager, I want to read guest reviews in their original language with a translation alongside, so that I can understand feedback from markets I do not speak.
Acceptance criteria
  1. Given a hotel with reviews on a FULL-tier org, when the "Ratings & Reviews" tab opens, then HotelReviews.vue lists each review with its rating, date and original text.
  2. Given a review carries a translation, when it renders, then the translation is shown as a blockquote beneath the original.
  3. Given a review has no overallRating, when it renders, then the rating element is omitted rather than showing zero stars.
  4. Given the org is on trial, when the tab opens, then UpgradeRequired is rendered instead of any review content.
  5. Given the hotel has zero reviews, when the tab opens, then the empty state "There aren't any reviews for this hotel yet" is shown.
Implementation notes

components/hotel/HotelReviews.vue over HotelReview {source, reviewDate?, reviewer.name, overallRating?, original{...}, translation?}; a count line sits above the list and the tab badge shows a formatted count such as 1.2k.

Edge cases & risks

reviewer.name is present in the read model even though the export schema strips it, so the portal is displaying PII that the export path deliberately removes — the two surfaces disagree about what is publishable.

REVW-03

Show the aggregate rating with a distribution breakdown

Built
Hotel ownerMust
As a Hotel owner, I want to see my average score and how reviews are distributed across the scale, so that I can tell a low average caused by a few disasters from one caused by broad mediocrity.
Acceptance criteria
  1. Given a hotel with rating data, when HotelRatingDetails.vue renders, then the large average score, a normalised star display and the total review count are shown.
  2. Given ratingDistribution, when the panel renders, then Excellent / Good / Average / Poor / Terrible gauges are drawn from the 1_star5_stars percentages via GaugesList.vue.
  3. Given the distribution percentages do not sum to 100 because of source rounding, when the gauges render, then they still render proportionally rather than overflowing the track.
  4. Given meta.rating.available is false, when the tab opens, then the rating panel is replaced by an empty state.
Implementation notes

components/hotel/HotelRatingDetails.vue plus the generic components/GaugesList.vue (0–1 ProgressBar list), reading HotelRating.ratingDistribution{1_star…5_stars .percent} and numberOfReviews.

Edge cases & risks

rating.numberOfReviews is also the default search sort key and is indexed in Atlas Search, so a source that inflates review counts both distorts this panel and quietly reorders every search result page.

REVW-04

Make ratings comparable across sources with different scales

Partial
Data analystMust
As a Data analyst, I want rating values that I can compare between hotels regardless of which source supplied them, so that my rankings are not an artefact of Google using 0–5 and Booking using 0–10.
Acceptance criteria
  1. Given a hotel rating, when it is returned by the API, then overallRating carries {min, max, average} so a consumer can tell which scale the average is on.
  2. Given the portal renders a rating, when the star display is drawn, then the average is normalised against min/max rather than assumed to be out of 5.
  3. Given two hotels sourced from different providers, when they appear side by side in search results, then their HotelRating displays use the same visual scale.
  4. Given min and max are missing or equal, when the value renders, then the raw average is shown with its scale stated rather than a nonsensical normalisation.
Implementation notes

HotelRating.overallRating{min,max,average} in the shared schema (Google 5, Booking 10); the HotelRating component in search results and HotelRatingDetails.vue on the profile both do their own normalisation.

Edge cases & risks

This is the single most likely source of a customer-visible correctness complaint in the whole review area, because it is invisible until someone sorts or thresholds on rating and gets a list that makes no sense.

REVW-05

Show category scores for the standard aspects

Built
Revenue managerShould
As a Revenue manager, I want per-category scores for cleanliness, comfort, facilities, location, staff, value and wifi, so that I can see which operational area is dragging my score down.
Acceptance criteria
  1. Given a hotel with rating.categoryScores, when the reviews tab renders, then scores for cleanliness, comfort, facilities, location, staff, valueForMoney, wifi and total are shown.
  2. Given all category scores are on a 0–10 scale, when they render, then they are labelled with that scale explicitly.
  3. Given a category is absent for this hotel, when the panel renders, then that category is omitted rather than shown as zero.
  4. Given no category scores exist at all, when the panel renders, then the section is hidden.
Implementation notes

HotelRating.categoryScores{cleanliness, comfort, facilities, location, staff, valueForMoney, wifi, total} (0–10), populated by the Booking and TripAdvisor category-score maps in the shared reference data.

Edge cases & risks

Category scores are effectively Booking- and TripAdvisor-shaped; Google-only hotels will show none, so a customer comparing a Google-only property against a Booking-covered one sees an apparent data gap that is really a coverage gap.

REVW-06

Show per-aspect sentiment from review mentions

Built
Hotel ownerShould
As a Hotel owner, I want to see which aspects guests mention and whether those mentions are positive or negative, so that I can prioritise fixes by what guests actually talk about.
Acceptance criteria
  1. Given a hotel with reviewMentionRatings, when HotelReviewMentionRatings.vue renders, then each aspect shows a stacked positive / neutral / negative bar plus a verdict chip.
  2. Given an aspect's sentiment.overall, when the chip renders, then it summarises the aspect in one word consistent with the bar proportions.
  3. Given the three percentages do not sum to exactly 100, when the bar renders, then it still fills the track without visual overflow.
  4. Given no mention ratings exist, when the section renders, then it is hidden rather than shown empty.
Implementation notes

components/hotel/HotelReviewMentionRatings.vue over reviewMentionRatings[]{title, category?, type?, sentiment{overall, negative.percent, positive.percent, neutral.percent}}, with source aspect names mapped through MentionRatingMap.ts.

Edge cases & risks

Aspect vocabularies differ per source and the mapping is a static file, so unmapped aspects fall through with their raw source title, producing inconsistent aspect names between hotels.

REVW-07

Generate an AI summary of what guests liked and complained about

Partial
Travel agency consultantShould
As a Travel agency consultant, I want a written summary of the review corpus with pros and cons, so that I can brief a client in a sentence instead of reading fifty reviews.
Acceptance criteria
  1. Given a hotel with reviews on a non-trial tier, when the reviews tab opens, then HotelReviewsSummary.vue requests GET /hotels/:id/reviews/summary and renders the summary text with "Guests liked" and "Common complaints" lists plus a footnote.
  2. Given the summary status is generating, when the client polls, then it retries every 3 seconds up to MAX_POLLS = 20 before showing an error card.
  3. Given my organisation is on the trial tier, when the tab renders, then the AI review summary is hidden along with the reviews themselves.
  4. Given the hotel has too few reviews to summarise, when the endpoint responds, then an explanatory state is shown rather than a fabricated summary.
Implementation notes

HotelReviewSummaryManager over the shared GeneratingSummaryPoller<T>; refresh is getHotelReviewsSummary(hotelId, refresh).

Edge cases & risks

An AI-written "Common complaints" list attached to a named hotel is a defamation-shaped risk if it over-generalises from a handful of reviews; the footnote is the only mitigation today.

REVW-08

Strip reviewer identity from every review export

Built
Organisation adminMust
As an Organisation admin, I want exported review data to contain no reviewer identities, so that ingesting it does not drag personal data into our warehouse.
Acceptance criteria
  1. Given a review with reviewer.name populated, when it is serialised through HotelReviewExport, then the reviewer field is absent from the output.
  2. Given an export of type reviews, when the gzipped JSON is produced, then every record conforms to HotelReviewExport with no reviewer key anywhere in the file.
  3. Given the review text itself contains a name typed by the guest, when the export runs, then the text is passed through unchanged — the guarantee is field-level, not content-level, and this is documented.
  4. Given the export completes, when the file is written, then an access-log row per huId with action export:create:reviews is recorded.
Implementation notes

HotelReviewExport in the shared schemas explicitly omits reviewer; ExportService emits one HotelReviewExport per review through Readable.from(dbStream) → transform → toJsonArray → gzip → GCS.

Edge cases & risks

The guarantee holds for exports but not for GET /hotels/{huId}/reviews, which still returns reviewer.name; a customer who pages the API instead of exporting gets PII that the export contract says they will not receive.

REVW-09

Export a whole list's reviews as a downloadable file

Built
Data analystMust
As a Data analyst, I want to export reviews for every hotel in a list in one job, so that I can run my own analysis over a comp set instead of clicking through profiles.
Acceptance criteria
  1. Given a ready list, when I choose the "Reviews" export type, then POST /lists/{listId}/exports with type: 'reviews' creates an export and the Exports drawer shows it as processing.
  2. Given the export finishes, when I click download, then GET /lists/{listId}/exports/{exportId}/download returns a signed URL valid for 15 minutes and the file downloads.
  3. Given my organisation is on the trial tier, when I run a reviews export, then it is capped at 100 hotels and the list view shows the banner explaining the trial limit.
  4. Given the export has passed its expiresAt (30 days), when I click download, then a 410 is returned and the row renders with an "Expired" status tag.
  5. Given the export fails, when the status is polled, then the generic message "Export failed. Please try again later or contact support if the problem persists." is shown.
Implementation notes

ListExportsManager (exportListHotels, startPolling, downloadExport) and components/list/ListExports.vue; files are gzipped JSON at {listId}/{exportId}/{type}-{timestamp}.json.gz in GCS.

Edge cases & risks

On the paid subscription model the reviews export is disabled on the review, unmatched, hotels and not-subscribed tabs ("Subscribe to hotels to unlock export"), which means the button a customer can see is frequently one they cannot press; the generic failure message also gives support nothing to work with.

REVW-10

Meter review access in the usage screen

Built
Organisation adminShould
As an Organisation admin, I want to see how much review data my organisation has consumed, so that I can reconcile usage against what we are paying for.
Acceptance criteria
  1. Given review views and exports have occurred, when I open /usage and pick a date range, then UsageTable.vue shows rows for reviews:view, export:create:reviews and export:download:reviews with a unique-hotel count each.
  2. Given I am a Ubio user (@ub.io), when I open the usage screen, then I can select any organisation via getAllOrganizations().
  3. Given no activity in the selected range, when the table renders, then "No usage data found" is shown.
  4. Given the stats request fails, when the screen renders, then an error state is shown rather than an empty table implying zero usage.
Implementation notes

GET /stats/ aggregates distinct huId per action from the accessLogs time-series collection (TTL 1 year, hourly granularity); views/Usage.vue with a default range of the last 30 days.

Edge cases & risks

Counts are distinct hotels, not requests, so a customer hammering the reviews endpoint for one hotel shows as usage of exactly one — usage reporting and cost of service are only loosely related, and none of this is reported to Stripe.

REVW-11

Filter reviews by date, language, rating and traveller type

Proposed
Hotel ownerMust
As a Hotel owner, I want to narrow the review list to a period, language, score band or traveller type, so that I can check whether a specific problem is recent or historic.
Acceptance criteria
  1. Given the reviews tab, when I set a date range, then only reviews with a reviewDate inside the range are listed and the count line updates.
  2. Given I select one or more languages, when the list refreshes, then only reviews whose original.languageCode matches are shown.
  3. Given I set a minimum and maximum rating, when the list refreshes, then only reviews inside that band are shown, normalised to the source's scale.
  4. Given I call GET /hotels/{huId}/reviews?from=&to=&lang=&minRating=&maxRating=, then the same filtering is available to API consumers with a paged response.
  5. Given a filter combination matches nothing, when the list renders, then an empty state distinguishes "no reviews match your filters" from "this hotel has no reviews".
Implementation notes

Requires reshaping canonicalReviewData1 from one array-bearing document per hotel into a queryable per-review collection, or at minimum a server-side filter and pagination layer over the array; the portal filter UI can reuse the chip pattern from HotelFilterBar.vue and filterDefs.ts.

Edge cases & risks

Traveller type is not captured in HotelReview at all today, so that dimension needs new extraction from the Booking and TripAdvisor scrapers before it can be filtered; shipping the filter with a mostly-empty facet would look broken.

REVW-12

Search review text

Proposed
Revenue managerShould
As a Revenue manager, I want to search the review corpus for a phrase, so that I can find every mention of "air conditioning" or "night noise" and quantify the complaint.
Acceptance criteria
  1. Given the reviews tab, when I type a phrase into the review search box, then matching reviews are listed with the phrase highlighted in both original and translation text.
  2. Given a phrase with no matches, when the search runs, then a "no matches" state is shown with the total corpus size for context.
  3. Given I search across a list rather than a single hotel, when results return, then they are grouped by hotel with per-hotel match counts.
  4. Given the corpus for a hotel is very large, when I search, then results are paged rather than loaded in one response.
Implementation notes

Extend the existing Atlas Search investment — a second index over a per-review collection, mirroring the pattern of atlas_custom_search_10 on canonicalData6 (lucene.standard, dynamic: false, explicit field list).

Edge cases & risks

Multi-language corpora make relevance ranking awkward; searching English against machine translations returns different results than searching the original language, and users will not realise which they are doing.

REVW-13

Chart review volume and score over time

Proposed
Hotel ownerShould
As a Hotel owner, I want a trend of review volume and average score by month, so that I can see whether a refurbishment or a management change actually moved the needle.
Acceptance criteria
  1. Given a hotel with dated reviews, when I open the Trends section, then a chart shows review count per month and rolling average score over the same axis.
  2. Given I change the window, when the chart re-renders, then the axis and aggregation adjust without a page reload.
  3. Given more than 20% of reviews lack a reviewDate, when the chart renders, then a caveat states how many reviews are excluded.
  4. Given fewer than three months of dated reviews exist, when the section renders, then an insufficient-data state is shown rather than a two-point line.
Implementation notes

Chart.js is already registered in the portal with bar and line controllers for HotelPriceInsights.vue, so the rendering stack exists; the aggregation would be a new endpoint alongside GET /hotels/:huId/price-insights.

Edge cases & risks

reviewDate is optional in HotelReview and coverage varies by source, so a volume trend can look like a collapse in guest activity when it is really a change in which source we last scraped.

REVW-14

Benchmark review performance against a competitor set

Proposed
Revenue managerShould
As a Revenue manager, I want my review scores next to my chosen comp set, so that I know whether a 8.2 is good or bad in my market.
Acceptance criteria
  1. Given a hotel and a comp set, when I open review benchmarking, then my average, review volume and each category score are shown against the comp-set median and range.
  2. Given I adjust the comp-set criteria, when I re-run, then the benchmark recalculates using the same weighting UI as price comparison.
  3. Given a competitor has ratings on a different source scale, when the benchmark renders, then values are normalised before comparison and the normalisation is stated.
  4. Given fewer than three competitors have review data, when the benchmark renders, then it warns that the median is unreliable rather than showing it as fact.
Implementation notes

Reuses GET /hotels/:huId/competitors and the CompetitorCriteria.vue weighting UI (stars, guest rating, property type, amenities, distance, presets, off levels) that already drives competitor discovery and the price matrix.

Edge cases & risks

Blocked on REVW-04 — benchmarking averages across mixed scales would produce confidently wrong league tables, which is worse than no benchmark at all.

REVW-15

Alert when sentiment or score drops

Proposed
Hotel ownerCould
As a Hotel owner, I want to be told when my score or an aspect's sentiment drops materially, so that I find out from you rather than from a booking slump.
Acceptance criteria
  1. Given I configure an alert on a hotel or list, when the rolling average drops by more than my threshold within my window, then I receive a notification.
  2. Given an aspect's negative sentiment share rises past my threshold, when the next refresh completes, then the alert fires naming the aspect.
  3. Given the drop is caused by a source being newly ingested rather than by new reviews, when the alert is evaluated, then it is suppressed and logged as a provenance event.
  4. Given I have no delivery channel configured, when an alert fires, then it is visible in the portal rather than silently dropped.
Implementation notes

Needs both a scheduled evaluation job — the PeriodicTaskRunner / Sleepbox pattern from the scrapers package is the natural home — and an outbound delivery mechanism. Neither exists: today there is exactly one transactional email (sendInvitationEmail) and no outbound webhook feature, despite the landing page advertising "push diffs to a webhook".

Edge cases & risks

False positives from re-scraping are the dominant failure mode; an alerting product that cries wolf on ingest artefacts will be muted within a week, so provenance-aware suppression is a hard prerequisite, not a refinement.

REVW-16

Trend each sentiment aspect independently

Proposed
Data analystCould
As a Data analyst, I want a trendline per review aspect rather than a single snapshot, so that I can see cleanliness improving while wifi degrades.
Acceptance criteria
  1. Given a hotel with mention ratings captured over multiple scrapes, when I open aspect trends, then each aspect has its own sparkline of positive share over time.
  2. Given an aspect first appears mid-series, when the trendline renders, then the series starts at first observation rather than implying zero before that.
  3. Given aspect names differ between sources, when trends are computed, then they are grouped by the mapped canonical aspect, not the raw source title.
  4. Given only one observation exists for an aspect, when it renders, then a point is shown with a "single observation" note instead of a line.
Implementation notes

Requires historising reviewMentionRatings, which today is overwritten on each canonical recompute; the change-feed proposed in HOTL-13 would carry these snapshots for free if rating is one of the tracked field groups.

Edge cases & risks

Scrape cadence is a 30-day staleness threshold with batches of 100 at concurrency 50, so aspect series will be irregularly sampled — trendlines must be drawn against real observation dates, not evenly spaced buckets.

REVW-17

Track owner response rates to reviews

Proposed
Hotel ownerCould
As a Hotel owner, I want to see how many reviews I have responded to and how fast, so that I can manage my team's reputation workload.
Acceptance criteria
  1. Given reviews with management responses, when the reviews tab renders, then each review shows whether it has a response and how long after the review it was posted.
  2. Given the corpus, when the summary renders, then overall response rate and median response time are shown, split by positive and negative reviews.
  3. Given a source does not expose responses, when the metric renders, then that source is excluded from the denominator and the exclusion is stated.
  4. Given no responses exist anywhere in the corpus, when the section renders, then it is hidden rather than showing 0%.
Implementation notes

HotelReview has no response field today; Booking and TripAdvisor both expose management responses in the payloads the existing scrapers already fetch, so this is an extraction and schema change rather than new crawling.

Edge cases & risks

Response-rate metrics attached to a named property are exactly the kind of comparison a hotel will dispute; without complete source coverage the number will be systematically understated.

REVW-18

Score reviews for authenticity

Proposed
OTA product managerCould
As an OTA product manager, I want a signal on how likely a review is to be inauthentic, so that I can avoid republishing obvious spam under my brand.
Acceptance criteria
  1. Given a review, when it is returned, then it carries an authenticity score and the contributing signals (burst timing, duplicate text across hotels, extreme-only rating patterns, missing stay signals).
  2. Given a review scores below a configurable floor, when the reviews tab renders, then it is excluded from the default view and from the AI summary input, but remains reachable.
  3. Given filtering excludes reviews, when the aggregate score renders, then it states whether it includes or excludes suppressed reviews.
  4. Given a source has already applied its own verification, when the score is computed, then that is treated as a positive signal rather than recomputed from scratch.
Implementation notes

Duplicate-text detection across hotels is feasible today because reviews are stored centrally per hotel with a source field; the same Jaccard / Levenshtein similarity utilities used by AddressMatcher in the link package can be reused for near-duplicate text.

Edge cases & risks

Publishing an authenticity score implicitly accuses named sources of hosting fake reviews; the score should probably be exposed as an internal filter first, and only surfaced externally with careful wording.

REVW-19

Break reviews down by source with counts

Proposed
Customer data engineerShould
As a Customer data engineer, I want to see how many reviews came from each source and each source's own average, so that I understand what is actually behind the blended number.
Acceptance criteria
  1. Given a hotel's review corpus, when the reviews tab renders, then a per-source table shows count, average on the source's native scale, normalised average and most recent review date.
  2. Given I select a single source, when the list refreshes, then only that source's reviews are shown.
  3. Given _dsNames.reviews records which source supplied the review document, when the breakdown renders, then it is consistent with that provenance rather than inferred from review source strings alone.
  4. Given only one source contributed reviews, when the breakdown renders, then it says so explicitly, because a single-source corpus is a material caveat.
Implementation notes

HotelReview.source and canonicalReviewData1._dsNames.reviews already carry everything needed; this is an aggregation endpoint plus a table, sharing the provenance vocabulary used by HotelReferences.vue.

Edge cases & risks

Because review documents are merged by source priority, a lower-priority source's reviews may be entirely absent rather than merely down-weighted, so the breakdown may reveal a coverage gap customers did not know they had.

REVW-20

Offer a configurable single normalised score

Proposed
Data analystShould
As a Data analyst, I want one normalised 0–100 score per hotel that I can configure, so that I can rank a mixed-source portfolio without writing my own normalisation.
Acceptance criteria
  1. Given any hotel with a rating, when the record is returned, then a normalisedScore field on 0–100 is present alongside the native overallRating.
  2. Given my organisation configures a weighting (for example weight sources, require a minimum review count, apply Bayesian shrinkage towards the market mean), when records are returned, then the score reflects my configuration.
  3. Given a hotel has fewer reviews than my configured minimum, when the score renders, then it is returned as null with a reason rather than as a confident number from three reviews.
  4. Given I search with minRating, when the filter is applied, then it can be applied against the normalised score rather than the native scale.
  5. Given my configuration changes, when I re-export a list, then the exported scores reflect the new configuration and the export records which configuration version produced them.
Implementation notes

Directly closes the named gap in REVW-04; needs a stored normalised field indexed in atlas_custom_search_10 so minRating filtering becomes meaningful, plus per-organisation configuration stored on organizations next to tier and billing.

Edge cases & risks

Per-organisation scoring makes the canonical record no longer identical for every customer, which cuts against the "one canonical record" positioning; it is safer to ship a fixed default normalisation first and make configuration an add-on.

REVW-21

Export reviews as CSV with sentiment columns

Proposed
Data analystShould
As a Data analyst, I want review exports as CSV with sentiment and aspect columns, so that I can load them into a spreadsheet or BI tool without writing a JSON flattener.
Acceptance criteria
  1. Given a list, when I choose a reviews export in CSV format, then I receive a flat file with one row per review including huId, source, date, native rating, normalised rating, language, aspect labels and a sentiment score.
  2. Given the CSV is produced, when it is inspected, then it contains no reviewer field, matching the HotelReviewExport guarantee.
  3. Given review text contains commas, quotes or newlines, when the CSV is written, then it is correctly quoted and escaped.
  4. Given the export exceeds the trial cap of 100 hotels, when a trial org runs it, then it is truncated with the existing trial banner rather than failing.
Implementation notes

The clone's ExportService only emits gzipped JSON, though the live portal offers CSV/JSON; the streaming pipeline (Readable.from → transform → toJsonArray → gzip → GCS) needs only a different transform stage to emit rows, preserving constant memory.

Edge cases & risks

Free-text review bodies in CSV are a classic injection and mangling risk (formula injection in spreadsheets from a leading =), and multi-language text needs an explicit BOM or encoding declaration to survive Excel.

REVW-22

Extract quotable review snippets for marketing with licensing caveats

Proposed
Hotel ownerCould
As a Hotel owner, I want the best representative guest quotes pulled out for me, so that I can use them in marketing without trawling the corpus — and know which ones I am allowed to use.
Acceptance criteria
  1. Given a hotel with reviews, when I open the quotes panel, then I see a handful of short, high-signal positive quotes with their aspect and date.
  2. Given each quote, when it renders, then the source is named and a licensing note states whether that source's terms permit republication.
  3. Given a source's terms do not permit republication, when quotes are selected, then that source's reviews are excluded from the panel entirely.
  4. Given a quote contains a personal name or contact detail, when it is selected, then it is either redacted or excluded.
  5. Given no quotes clear the licensing and safety checks, when the panel renders, then it explains why rather than showing an empty box.
Implementation notes

Builds on the AI review summary pipeline behind GET /hotels/:id/reviews/summary and on AccessManager.licensedSources, which already gates the source_data export with the tooltip "Upgrade to a plan with the source data addon…".

Edge cases & risks

This is the highest legal-risk story in the epic: republishing verbatim OTA review text is a redistribution question, not a technical one, and the feature should not ship without source-by-source legal sign-off encoded in licensedSources.

REVW-23

State and enforce a compliance guarantee for review text

Proposed
Organisation adminMust
As an Organisation admin, I want a documented, testable guarantee about what personal data review responses can contain, so that I can complete a data-protection assessment before we go live.
Acceptance criteria
  1. Given the API reference, when I read the reviews section, then it states exactly which fields may contain personal data, that reviewer is stripped from exports, and what the retention period is.
  2. Given GET /hotels/{huId}/reviews, when it responds, then reviewer is omitted by default and only returned if a documented, separately-scoped parameter is used — aligning the API with the export contract.
  3. Given free-text review bodies, when they are ingested, then an automated redaction pass removes obvious contact details (emails, phone numbers, URLs) and records that redaction occurred.
  4. Given a deletion request referencing a specific review, when it is processed, then the review is removed from canonicalReviewData1 and excluded from future exports and summaries.
  5. Given an integration test suite runs, when it checks a reviews export fixture, then a test fails if any reviewer identity field appears in the output.
Implementation notes

Closes the mismatch between HotelReviewExport (strips reviewer) and the read endpoint plus HotelReviews.vue (both display reviewer.name). The accessLogs TTL of one year is the only retention policy that exists today; review documents themselves have none.

Edge cases & risks

Review documents are one array per hotel, so a single-review deletion is a document rewrite; at scale, deletion requests would need a tombstone approach or the per-review collection proposed in REVW-11.

TRST 9 built1 partial10 proposed

Data provenance, confidence & data quality

Every canonical hotel record is a merge of up to five sources, and _dsNames records, per field group, which source won. On top of that the portal exposes reference IDs for every known source, per-field consensus badges, an overall confidence pill, and six data-availability flags — the raw material of a trust story. This epic covers what exists and then proposes turning provenance from a display detail into a product: dashboards, overrides, audits, dispute workflows and licence metadata.

TRST-01

Record which source supplied each field group

Built
Customer data engineerMust
As a Customer data engineer, I want each part of a hotel record to carry the source that supplied it, so that I can trace any value back to where it came from.
Acceptance criteria
  1. Given a canonical hotel document, when it is read, then _dsNames records the contributing source for each of refs, info, location, rating, features, images and rooms.
  2. Given a hotel assembled from more than one source, when _dsNames is inspected, then different field groups may legitimately name different sources.
  3. Given a field group has never been populated by any source, when the record is read, then that group is absent from _dsNames rather than defaulting to a source name.
  4. Given a source is re-ingested with the same values, when the canonical record is recomputed, then _dsNames is unchanged.
Implementation notes

CanonicalData._dsNames on canonicalData6, written by HotelDataUpdateService.combineData as it walks FIELD_GROUPS = [refs, info, location, rating, features, images, rooms].

Edge cases & risks

Provenance is recorded at field-group granularity, not per field, so a record whose info came from Booking gives no way to know whether the phone number specifically came from Booking — the consensus badges partially compensate, but the underlying model is coarser than the UI implies.

TRST-02

Merge sources by a fixed priority order

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want a deterministic rule for which source wins a field group, so that the same inputs always produce the same canonical record.
Acceptance criteria
  1. Given multiple sources for a hotel, when the canonical record is recomputed, then DS_NAMES is walked in priority order GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia.
  2. Given a higher-priority source has already written a field group, when a lower-priority source is processed, then it does not overwrite that group and _dsNames[group] is unchanged.
  3. Given a higher-priority source has no data for a group, when a lower-priority source has data, then the lower-priority source populates the group and is recorded as its provenance.
  4. Given the same source data is replayed, when the merge runs again, then the output record is byte-identical apart from updatedAt.
Implementation notes

HotelDataUpdateService.combineData in the import package; the pipeline is validate → findHuId → upsert into sourceHotelData5 → recompute canonical, with computeMeta deriving availability flags (images and reviews are sticky).

Edge cases & risks

Priority is global, hard-coded and all-or-nothing per group: a source that is authoritative for addresses but weak on amenities cannot be preferred selectively, and because groups are large, one strong field can drag six weak ones into the canonical record.

TRST-03

Show every known source identifier for a hotel

Built
Customer data engineerMust
As a Customer data engineer, I want to see the Hotel Universe ID plus every source ID we hold for a property, so that I can join your record to the identifiers already in my systems.
Acceptance criteria
  1. Given a full profile, when HotelReferences.vue renders, then the Hotel Universe ID is shown alongside refs.main (GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia, AllTopHotels).
  2. Given the hotel has extra OTA references, when the panel renders, then refs.extra slugs are listed from the 32 REF_EXTRA_OTA_NAMES (agoda, hotels_com, kayak, trivago, priceline, traveloka and the rest).
  3. Given any identifier row, when I click its copy button, then the value is copied to the clipboard and a confirmation toast is shown.
  4. Given a reference type is not held for this hotel, when the panel renders, then that row is omitted rather than shown blank.
Implementation notes

components/hotel/HotelReferences.vue; HotelRefs on the canonical document holds the five main IDs and each is separately indexed in Mongo and as a token field in atlas_custom_search_10, which is what makes hotelIdType + hotelId search work.

Edge cases & risks

The extra OTA references are portal-side knowledge and are not part of the API clone's HotelRefs schema, so what the panel can display and what the documented schema promises are not the same set — an API consumer expecting an Agoda ID from the schema will not find it there.

TRST-04

Badge fields where sources disagree

Built
Data analystShould
As a Data analyst, I want to see which specific fields the sources disagree about, so that I know which values to treat with caution.
Acceptance criteria
  1. Given consensus.info[field] = {agreeing, total}, when HotelInfo.vue renders that field, then HotelInfoFieldConsensus.vue shows a badge reflecting the agreement ratio.
  2. Given agreement is below the low-confidence threshold, when the field renders, then a "Low confidence" badge is shown next to it.
  3. Given only one source supplied the field (total = 1), when it renders, then no false-confidence badge is shown implying unanimous agreement.
  4. Given no consensus data is present for a field, when it renders, then the field is shown without a badge rather than defaulting to low confidence.
Implementation notes

components/hotel/HotelInfoFieldConsensus.vue driven by the consensus block returned with the profile; the badge sits inline in the HotelInfo.vue field rows.

Edge cases & risks

Agreement is computed on raw values, so cosmetic differences (trailing punctuation in an address, "+44 20" vs "020") count as disagreement and generate low-confidence badges on fields that are actually fine — which trains users to ignore the badge.

TRST-05

Summarise record confidence in a single pill

Built
OTA product managerShould
As an OTA product manager, I want a one-glance confidence indicator for a hotel record, so that I can triage which properties need human review before they go live on my site.
Acceptance criteria
  1. Given per-field consensus data, when the profile renders, then an overall High / Medium / Low confidence pill is shown on the info panel.
  2. Given the pill, when I hover it, then it explains what drove the rating (how many fields agreed, across how many sources).
  3. Given a record built from a single source, when the pill renders, then it does not read High purely because there was nothing to disagree with.
  4. Given no consensus data at all, when the panel renders, then the pill is omitted rather than shown as Low.
Implementation notes

Derived client-side in HotelInfo.vue from the same consensus.info[field] = {agreeing, total} structure that drives the per-field badges.

Edge cases & risks

The pill is a portal-only derivation — it is not a field on the API record, so an API consumer cannot filter or sort on confidence and cannot reproduce the portal's rating; two surfaces telling different stories about the same hotel is a support burden.

TRST-06

Expose data-availability flags and let customers filter on them

Built
Customer data engineerMust
As a Customer data engineer, I want to know which parts of a record exist before I request it, so that I can plan around gaps instead of discovering them at render time.
Acceptance criteria
  1. Given any hotel record, when it is returned, then meta carries availability flags for location, rating, features, reviews, images and rooms.
  2. Given search results, when HotelResultsTable.vue renders the Data column, then six availability icons reflect those flags per row.
  3. Given I search with hasLocation, hasFeatures, hasRating, hasReviews, hasImages or hasRooms, when results return, then only hotels whose corresponding flag is true are included.
  4. Given a source is removed or a scrape returns nothing, when computeMeta runs, then the images and reviews flags remain sticky rather than flipping to false.
Implementation notes

HotelMeta on canonicalData6, recomputed by computeMeta; the flags are indexed as meta.*.available in atlas_custom_search_10 and applied as equals clauses in compound.must, with matching filter defs in filterDefs.ts.

Edge cases & risks

Sticky image and review flags mean a hotel can advertise availability for data that is no longer served — the flag is "has ever had", not "has now", which is exactly backwards from what a customer filtering on hasImages expects.

TRST-07

Show nearby points of interest grouped by type

Built
Corporate travel buyerShould
As a Corporate travel buyer, I want to see what is around a hotel grouped by category with distances, so that I can judge convenience without opening a map.
Acceptance criteria
  1. Given a hotel with surroundings data, when HotelSurroundings.vue renders, then POIs are grouped as airports, beaches, supermarkets, dining (restaurants and cafés/bars), landmarks (nearby and top), public transport (bus, metro, train) and natural beauty (mountain, lake, forest, peak, waterfall).
  2. Given each POI, when it renders, then a localised distance and travel time are shown.
  3. Given a group has no entries, when the panel renders, then that group heading is omitted.
  4. Given no surroundings data exists, when the panel renders, then the section is hidden rather than shown empty.
Implementation notes

components/hotel/HotelSurroundings.vue, populated from source attractions data captured in sourceHotelData5.

Edge cases & risks

Distances and travel times are source-supplied rather than recomputed from our own coordinates, so a POI list can contradict the location.coordinates shown one panel above it, and units differ by source (the TripAdvisor scraper defaults to MILES).

TRST-08

Display sustainability certifications

Built
Corporate travel buyerShould
As a Corporate travel buyer, I want to see a property's sustainability certifications, so that I can hold suppliers to our ESG policy with evidence rather than claims.
Acceptance criteria
  1. Given a hotel with certifications, when HotelSustainability.vue renders, then each certification is shown with its logo, name and issuer detail rows.
  2. Given a certification has no logo, when it renders, then the name and issuer are still shown without a broken image.
  3. Given no certifications are held, when the panel renders, then the section is hidden.
  4. Given a certification is displayed, when it renders, then the source that supplied it is identifiable so a buyer can assess the claim.
Implementation notes

components/hotel/HotelSustainability.vue on the hotel profile.

Edge cases & risks

Certifications are scraped self-declarations with no expiry date captured, so an expired or lapsed certification will continue to display indefinitely — a material problem for a compliance-adjacent field.

TRST-09

Display regulatory licence numbers with copy-to-clipboard

Built
Organisation adminShould
As an Organisation admin, I want a property's licence and registration numbers available to copy, so that I can satisfy regulatory obligations in markets that require them.
Acceptance criteria
  1. Given a hotel with licence data, when HotelCompliance.vue renders, then each licence number is listed with a copy button.
  2. Given I click copy, when the action completes, then the value is on the clipboard and a toast confirms it.
  3. Given no licence numbers are held, when the panel renders, then the section is hidden.
  4. Given a licence value is present but malformed for its country's format, when it renders, then it is displayed as received rather than silently reformatted.
Implementation notes

components/hotel/HotelCompliance.vue, using the same clipboard-plus-toast pattern as HotelReferences.vue and the access-token secret dialog.

Edge cases & risks

Licence numbers are used for legal compliance in markets like Italy, Spain and Japan, so a stale or wrong value carries real consequence for the customer publishing it — yet there is no verification, no format validation and no "last verified" date on the field.

TRST-10

Track scraping freshness per hotel and source

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to know when each source was last successfully scraped for a hotel, so that I can tell a stale record from a broken one.
Acceptance criteria
  1. Given a hotel, when I inspect scrapingStatuses, then per-source entries for GooglePlaces, Booking and TripAdvisor show {status: success | error | scraping, error, updatedAt} for hotel, rooms and reviews.
  2. Given a source's last success is older than staleTimeThresholdMs (30 days), when the scraping service selects work, then that hotel is eligible for re-scraping.
  3. Given a source has a null status, when work is selected, then the hotel is treated as never-scraped and is eligible.
  4. Given a scrape fails, when the status is written, then the error is recorded against the source rather than the whole hotel.
Implementation notes

AbstractScrapingService.getStaleHotels with batchLimit = 100, concurrency = 50, staleTimeThresholdMs = 30 days, scheduled by PeriodicTaskRunner.schedule(task, interval, name) with SCRAPING_INTERVAL default 60 s.

Edge cases & risks

The combination of untried error retries and sticky meta flags means a permanently failing hotel keeps advertising data availability while its content ages out — the two defects compound into a record that looks healthier than it is.

TRST-11

Build a data-quality dashboard per list and per organisation

Proposed
Data analystMust
As a Data analyst, I want completeness, freshness and conflict metrics across a list, so that I can report on the quality of the data we are buying rather than sampling it by hand.
Acceptance criteria
  1. Given a ready list, when I open its Data quality tab, then I see field-level completeness (percentage of hotels with each field populated) across info, location, rating, features, images and rooms.
  2. Given the same list, when the dashboard renders, then a freshness distribution shows how many hotels were last verified within 7 / 30 / 90 / 90+ days.
  3. Given the same list, when the dashboard renders, then a conflict rate shows the share of hotels carrying at least one low-confidence field.
  4. Given I switch to the organisation view, when it renders, then the same metrics are aggregated across every list my org holds.
  5. Given a list is still processing, when I open the tab, then a processing state is shown rather than metrics computed from a partial list.
Implementation notes

Builds on GET /lists/{listId}/stats (which today returns only {total, hotels}), the meta availability flags, the consensus block and the freshness data from TRST-10. The chart stack is already present (chart.js in HotelPriceInsights.vue and AiDataInsights.vue).

Edge cases & risks

Computing completeness over a dynamic list means computing over a moving target; metrics need to be pinned to a snapshot, or two people looking at the same list on the same day will report different numbers.

TRST-12

Let customers override the source priority order

Proposed
OTA supply managerShould
As an OTA supply manager, I want to tell you which sources I trust for which fields, so that the canonical record I consume matches the editorial judgement my business already has.
Acceptance criteria
  1. Given my organisation, when I open source-priority settings, then I can reorder DS_NAMES globally and override the order per field group.
  2. Given an override is saved, when I fetch a hotel, then the returned record reflects my priority order and _dsNames names the source my order selected.
  3. Given my override names a source my organisation is not licensed for, when I save it, then it is rejected with a clear message referencing licensedSources.
  4. Given no override is configured, when I fetch a hotel, then the platform default order (GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia) applies.
  5. Given I change my override, when I next export a list, then the export reflects the new order and records which override version produced it.
Implementation notes

The merge is already a deterministic walk over DS_NAMES per field group in combineData, so a per-organisation order can be applied as a view-time re-merge from sourceHotelData5 rather than by storing N canonical variants.

Edge cases & risks

Re-merging at read time for every request is expensive at 1M+ hotels and breaks the current single-document read; the alternative — materialising per-org canonical records — multiplies storage and undermines the "one canonical record" promise on the landing page.

TRST-13

Let customers inspect the raw source record

Proposed
Customer data engineerShould
As a Customer data engineer, I want to pull the raw source document behind a canonical field, so that I can audit a value my own team is disputing.
Acceptance criteria
  1. Given a hotel and a source, when I call GET /hotels/{huId}/sources/{dsName}, then I receive that source's SourceHotelData document including dsHotelId, dsUrl and crawledAt.
  2. Given my organisation is not licensed for that source, when I call the endpoint, then I receive a 403 explaining the source-data licensing requirement.
  3. Given the source has never been ingested for this hotel, when I call the endpoint, then I receive a 404 rather than an empty document.
  4. Given the raw record is returned, when it is inspected, then it is unmodified apart from any mandated redaction, so it is genuinely usable as audit evidence.
  5. Given the call succeeds, when it completes, then it is access-logged as a distinct action so source-data consumption can be metered separately.
Implementation notes

sourceHotelData5 already stores exactly this, keyed uniquely by {dsName, dsHotelId} with a huId link; gating reuses AccessManager.licensedSources, which already governs the source_data export type in ListHeader.vue.

Edge cases & risks

Raw source documents are the most redistribution-sensitive artefact the platform holds; per-source licensing must be enforced server-side, not just hidden in the UI, and the endpoint should be metered from day one or it becomes an unpriced bulk-extraction path.

TRST-14

Run a customer-facing correction and dispute workflow with an SLA

Proposed
OTA product managerShould
As an OTA product manager, I want a formal route to dispute a value with a committed turnaround, so that data errors have an owner and a deadline instead of becoming a support thread.
Acceptance criteria
  1. Given a field on a hotel, when I raise a dispute, then I receive a reference number and a stated target resolution date based on my tier's SLA.
  2. Given a raised dispute, when I open my Disputes screen, then I see status (open, investigating, accepted, rejected), the field, the hotel and the elapsed time against the SLA.
  3. Given a dispute is accepted, when the fix is applied, then the corrected value appears on the hotel and the dispute closes with a note naming the change.
  4. Given a dispute breaches its SLA, when the deadline passes, then it is flagged internally and visible as breached to the customer rather than quietly ageing.
  5. Given I raise a duplicate dispute for a field already under investigation, when I submit it, then it is linked to the existing case instead of creating a second one.
Implementation notes

Pairs with the correction workflow in HOTL-15 but adds the commercial wrapper: a dispute collection, a Ubio-side queue reusing the approve/reject interaction from ListReviewQueue.vue, and per-tier SLA values stored alongside plan configuration.

Edge cases & risks

SLAs are a commercial commitment, not a feature — shipping the UI without operational capacity to meet the turnaround converts a data-quality complaint into a contractual one. Notification also depends on outbound email or webhooks, neither of which exists beyond sendInvitationEmail.

TRST-15

Detect anomalies in canonical records automatically

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to be told when a hotel's record changes implausibly, so that I catch a broken scraper before customers do.
Acceptance criteria
  1. Given a canonical recompute, when a hotel loses more than a configurable share of its amenities, images or rooms in a single update, then the change is flagged for review and the previous values are retained pending a decision.
  2. Given a batch run, when an unusually large share of hotels in one country or one source shows the same drop, then a source-level incident is raised rather than thousands of individual flags.
  3. Given a flagged change, when an operator reviews it, then they can accept it (the source genuinely changed) or reject it (the scrape was broken), and rejection blocks that source's writes for the affected group until cleared.
  4. Given a flag is raised, when a customer views the hotel, then the record still renders — anomaly detection must never take a property offline.
  5. Given no anomalies are detected in a run, when the job completes, then it records a clean result rather than silence, so a dead detector is distinguishable from a quiet one.
Implementation notes

Hooks into HotelDataUpdateService.combineData, which already has both the previous and the incoming values in hand; incidents can be scheduled and surfaced by the same PeriodicTaskRunner pattern the scrapers use.

Edge cases & risks

The scrapers spoof mobile app clients (Booking.App/47.1 iOS/17.5, protobuf Google Travel URLs, persisted TripAdvisor GraphQL hashes) — when a remote changes its response shape the failure mode is usually empty-but-valid data, which is precisely what this story must catch and is also precisely what looks like a legitimate change.

TRST-16

Queue duplicate and merge candidates for human review

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want a queue of probable duplicate hotels with the evidence for and against merging, so that I can resolve them deliberately rather than letting the resolver guess.
Acceptance criteria
  1. Given two canonical records that score above a similarity threshold, when the detector runs, then a merge candidate is queued with both records side by side.
  2. Given a candidate, when it renders, then per-signal confidence is shown for name, address, postal code and geo, mirroring the review-queue expansion panel.
  3. Given I approve a merge, when it is applied, then one huId survives, the other is tombstoned with a redirect so existing customer references keep resolving, and all refs are consolidated.
  4. Given I reject a candidate, when the detector next runs, then that pair is suppressed rather than re-queued.
  5. Given a merge is applied, when a customer requests the retired huId, then they receive the surviving record with an indication that the ID was merged, not a 404.
Implementation notes

Reuses the link package's AddressMatcher scoring — nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accept at ≥ 0.7, Jaccard plus Levenshtein, Haversine on coordinates — and the ListReviewQueue.vue interaction pattern with per-signal confidence pills.

Edge cases & risks

The known matcher limitation is that "123 Main St" and "124 Main St" are not distinguished well, which is exactly the case that produces confident false merges; merges must be reversible, and retiring a huId that customers have stored is a contract change however carefully redirected.

TRST-17

Report coverage by country and source

Proposed
Ubio sales repShould
As a Ubio sales rep, I want to show a prospect our coverage in the markets they care about, so that I can answer "do you have Portugal?" with a number instead of a claim.
Acceptance criteria
  1. Given the coverage report, when I select a country, then I see the hotel count, the share with location, rating, features, images, rooms and reviews, and the source mix behind them.
  2. Given a prospect's CSV of hotels, when it is matched, then the report shows what share of their file we cover, broken down by country.
  3. Given a country with fewer than a threshold number of hotels, when it renders, then it is marked as thin coverage rather than shown as a bare number.
  4. Given the report is generated, when it renders, then it states the date and the record count it was computed from, so it can be quoted safely in a proposal.
Implementation notes

location.countryCode is already a lowercased token field in atlas_custom_search_10 and meta.*.available flags are indexed, so the aggregation is a $searchMeta-style rollup rather than a new pipeline; the prospect-file variant reuses the existing CSV upload and matchRecordsToHotelsByIds flow.

Edge cases & risks

Coverage counts sourced from the marketing figure HOTELS_INDEXED = 1M+ and coverage counts computed from canonicalData6 must not disagree publicly; a report that undercuts the landing page is worse than no report.

TRST-18

Publish per-field freshness commitments

Proposed
OTA product managerCould
As an OTA product manager, I want documented refresh commitments per field, so that I can decide which of your fields I cache and for how long.
Acceptance criteria
  1. Given the documentation, when I read the hotel schema, then each field group states a target refresh interval and the current measured p50 and p95 age.
  2. Given a hotel record, when it is returned, then each field group carries a lastVerifiedAt so a consumer can apply their own staleness policy.
  3. Given a field group is older than its stated commitment, when the record is returned, then it is marked stale rather than presented as current.
  4. Given measured performance falls short of the commitment for a market, when the status page renders, then it is reported rather than hidden.
Implementation notes

The measurement basis exists in scrapingStatuses and the 30-day staleTimeThresholdMs; publishing it means exposing TRST-10's internal data and holding the scraping service (batches of 100, concurrency 50) to a stated rate.

Edge cases & risks

Publishing a commitment converts an internal target into an external promise across 1M+ hotels on scrapers that depend on spoofed third-party clients — a single remote blocking us would breach the commitment globally, so the commitment should be tiered and market-scoped from the outset.

TRST-19

Include provenance and confidence columns in exports

Proposed
Customer data engineerMust
As a Customer data engineer, I want exports to carry the source and confidence behind each field group, so that my downstream pipeline can apply its own trust rules without a second API call per hotel.
Acceptance criteria
  1. Given a full export, when it is produced, then each record includes _dsNames per field group, per-field consensus counts and a lastVerifiedAt per group.
  2. Given a CSV export, when it is produced, then provenance appears as suffixed columns (for example info.name, info.name__source, info.name__confidence) rather than as nested JSON in a cell.
  3. Given my organisation is not licensed for a source, when the export runs, then that source is not named in the provenance columns and the omission is documented.
  4. Given a customer opts out of provenance columns, when they configure the export, then the file returns to the current shape so existing importers do not break.
  5. Given a preview export, when it is produced, then provenance columns are excluded, because preview records carry only the preview subset.
Implementation notes

ExportService streams HotelProfileSchema (full), HotelPreviewSchema (preview) and HotelReviewExport (reviews) through a constant-memory transform; adding provenance is a schema and transform change, not a pipeline change. The source_data export type already in the portal establishes the licensing precedent.

Edge cases & risks

Provenance roughly doubles the column count on a full export, which increases file size and download time against a 15-minute signed-URL window and a 30-day retention; the opt-out is essential, not a nicety.

TRST-20

Attach licence and attribution metadata to every source

Proposed
Organisation adminMust
As an Organisation admin, I want to know exactly what I may redistribute from each source, so that our legal team can sign off on using this data in a public product.
Acceptance criteria
  1. Given any source, when I query the licence catalogue, then I see for each dsName whether derived values, verbatim text, images and reviews may be redistributed, plus any required attribution string.
  2. Given a hotel record, when it is returned, then each field group carries the redistribution class implied by its provenance.
  3. Given my organisation's licensedSources, when I attempt an export whose provenance includes an unlicensed source, then the export is blocked with the existing "Upgrade to a plan with the source data addon…" upgrade path rather than silently including it.
  4. Given a required attribution exists, when I render a field derived from that source, then the attribution string is available in the payload so I can display it.
  5. Given a licence changes, when the catalogue is updated, then affected customers are identifiable from their export history so they can be notified.
Implementation notes

AccessManager.licensedSources already gates the source_data export type in the portal; this makes the same concept a first-class, per-source, per-artefact catalogue that the API enforces, rather than a single list that only toggles one button.

Edge cases & risks

This is the story that determines whether several other proposals in this backlog — raw source inspection, verbatim review quotes, image redistribution, PDF factsheets — can legally ship at all, so it is a dependency rather than an enhancement. Enforcement must be server-side; a UI-only gate is not a licence control.

Part 4

AI, agents & the developer platform

AIA 9 built3 partial11 proposed

AI assistant & natural-language search

Hotel Universe positions itself as "AI-first, by design": the portal's default search experience is a conversation, not a filter form. This epic covers the two search modes, the natural-language search endpoint that compiles a sentence into filter chips, and the full assistant at /ai/assistant — streaming replies, conversation history, typed artifacts, the action toolbar that turns an answer into a list, the context meter, and support escalation. It also covers the gaps: session-only persistence, a mock-data recommendations screen, and the absence of citations, guardrails and confirmation steps before the assistant writes anything.

AIA-01

Switch between assistant search and filter search

Built
OTA product managerMust
As an OTA product manager, I want to switch between asking the assistant and building a query with filters, so that I can start conversationally and then take manual control without losing my place.
Acceptance criteria
  1. Given I am on /search with no active filters and no explicit search intent, when the page renders, then the inline AiAssistant is shown as the default mode.
  2. Given I am in assistant mode, when I press the .pr-search-mode-toggle button labelled "Build with filters", then HotelFilterBar and the filter chips replace the assistant pane.
  3. Given I switched to filter mode, when I reload the portal or open /search in a new tab, then filter mode is restored from localStorage hotel-universe:search-mode.
  4. Given localStorage is unavailable or holds an unrecognised value, when the page loads, then the portal falls back to assistant mode rather than rendering an empty pane.
Implementation notes

views/HotelsSearch.vue owns the toggle; the preference key is hotel-universe:search-mode, read and written through LocalStorageManager. The driver.js onboarding tour drives this same toggle by clicking [data-tour-step="search-mode-toggle"] to force a mode per step.

Edge cases & risks

The mode is per-browser, not per-user, so a shared machine inherits a colleague's preference; HotelsTable independently renders AiSearch when there are no filters, so two different AI entry points can appear for what feels to the user like the same state.

AIA-02

Turn a natural-language query into search filters

Built
Travel agency consultantMust
As a travel agency consultant, I want to type "4★+ hotels in Barcelona with a pool, walkable to the centre" and get real filters, so that I do not have to learn the filter vocabulary before I can search.
Acceptance criteria
  1. Given I type a sentence into the AiSearch composer, when I submit it, then the portal calls POST /search/ai and receives a filter object.
  2. Given the endpoint returns filters, when they are applied, then hotelManager.applyFilters() normalises them, writes them to the URL query string and navigates to /search, and every applied filter appears as an editable chip in HotelFilterBar.
  3. Given the query is ambiguous and no filters can be derived, when the response returns, then a "Search name '<text>'" fallback is offered rather than an empty result table.
  4. Given POST /search/ai fails or times out, when the error is surfaced, then the composer stays populated with my text so I can retry without retyping.
  5. Given the derived filters resolve to zero hotels, when the table renders, then "No results found. Please try different search parameters." is shown with the chips still editable.
Implementation notes

views/AiSearch.vueapi.aiSearch(query, pageToken?)POST /search/ai; the resulting filters are handed to HotelManager.applyFilters, which is the same path the manual filter bar uses. Tracked as ai_search_executed via portalTracking.

Edge cases & risks

The URL is the source of truth for filters, so an over-eager model can silently produce a very broad query the user did not intend; the underlying Atlas Search builder returns nothing at all when no clause is produced ($match {_id: 'impossible-id'}), which reads as "no hotels" rather than "no filters".

AIA-03

Stream assistant answers with live status text

Built
Revenue managerMust
As a revenue manager, I want the assistant's answer to stream in with an indication of what it is currently doing, so that a multi-step search does not look like a hung page.
Acceptance criteria
  1. Given I send a message, when the request is in flight, then a typing indicator is shown and state.typing is true.
  2. Given the server emits a status event, when it arrives, then state.statusText is rendered above the reply (for example while the assistant is searching or pulling prices).
  3. Given content deltas arrive, when each delta is received, then the reply grows in place with the escaped inline-markdown subset rendered, without the view jumping.
  4. Given the stream ends, when onDone fires, then the typing indicator clears and ai_assistant_replied is tracked.
  5. Given the connection drops mid-stream, when the failure is detected, then the partial reply is kept, an error message is shown and the composer is re-enabled.
Implementation notes

api.aiAssistantChatStream(...) posts to POST /ai/assistant/chat/stream and dispatches onStatus / onDelta / onDone into AiAssistantManager.state; a non-streaming POST /ai/assistant/chat also exists as a fallback path. Sends are tracked as ai_assistant_sent.

Edge cases & risks

Long tool chains (search → match → list → prices) can hold the stream open well past a proxy's idle timeout; because streaming is not resumable, a dropped connection loses the tool work already done server-side.

AIA-04

Browse, search and delete past conversations

Built
Data analystShould
As a data analyst, I want a sidebar of my previous conversations that I can search and prune, so that I can return to an earlier analysis instead of re-asking.
Acceptance criteria
  1. Given I open /ai/assistant, when the left rail loads, then GET /ai/assistant/conversations populates the History list and "New conversation" is available at the top.
  2. Given I type into the history search box, when the term matches no conversation, then "No matching conversations." is shown; when I have none at all, then "No conversations yet." is shown.
  3. Given I select a conversation, when it loads via GET /ai/assistant/conversations/:id, then its messages and artifacts are restored into the chat pane.
  4. Given I delete a conversation, when I confirm the dialog, then DELETE /ai/assistant/conversations/:id is called and the row is removed from the rail.
  5. Given I press "New conversation", when the reset completes, then conversationId is cleared, the prompt cards return and ai_assistant_new_conversation_started is tracked.
  6. Given conversation loading fails, when the error is caught, then the rail shows an error state and the current conversation remains usable.
Implementation notes

AiAssistantManager holds state.conversations and state.sidebarCollapsed (persisted to hotel-universe:ai-assistant-sidebar-collapsed); reset also calls POST /ai/assistant/reset. Deletion goes through the shared $confirm.require dialog.

Edge cases & risks

History search appears to filter client-side over the loaded page, so older conversations may be invisible to search; deletion is irreversible and there is no export of a transcript before it goes.

AIA-05

Offer starter prompts for search and product help

Built
Trial userShould
As a trial user, I want suggested prompts when the assistant is empty, so that I learn what the platform can do without reading documentation first.
Acceptance criteria
  1. Given a fresh conversation, when the empty screen renders, then prompt cards are shown in two groups: Search (Find a hotel, Compare prices, Build a list, Summarise reviews) and Product help (Lists and exports, Metasearch, Direct pricing, Billing).
  2. Given I click a prompt card, when the click is handled, then its text is sent as my message and the empty screen is replaced by the conversation.
  3. Given I am on a trial organisation without metasearch access, when I use the "Compare prices" card, then the assistant explains the tier restriction rather than returning an unexplained error.
  4. Given the assistant has produced at least one reply, when the view re-renders, then the prompt cards are hidden and state.suggestions drives any follow-up chips instead.
Implementation notes

Prompt cards live in views/AiAssistant.vue; the empty state and composer carry the tour anchors assistant-empty and assistant-composer used by the driver.js onboarding tour.

Edge cases & risks

The Product-help group answers questions about billing and direct pricing, so its answers must track pricing changes — a stale system prompt turns the assistant into a source of wrong commercial advice.

AIA-06

Render hotel, review and image artifacts in the answer

Built
OTA supply managerMust
As an OTA supply manager, I want the assistant's answers to include real hotel cards, review snippets and photo galleries rather than prose, so that I can judge the result without leaving the chat.
Acceptance criteria
  1. Given the assistant runs a search, when the reply renders, then a hotel_search artifact shows the active filter chips, a "Top matches" list and the result count.
  2. Given the assistant resolves a single property, when the reply renders, then a hotel artifact is shown, and a location artifact when the answer is geographic.
  3. Given I ask about guest sentiment, when the reply renders, then a hotel_reviews artifact shows review snippets, and an images artifact shows galleries grouped per hotel.
  4. Given the assistant returns content that matches no known artifact type, when it renders, then it falls back to the generic artifact instead of dropping the payload.
  5. Given an artifact references a hotel the organisation cannot view on its tier, when I open it, then the standard upgrade path is shown rather than a raw 402.
Implementation notes

Artifact types are enumerated as AiAssistantArtifactType in views/AiAssistant.vue: hotel_search, hotel, hotel_reviews, images, list_created, list, list_collection, pricing, location, generic.

Edge cases & risks

Image artifacts render encoded images.automation.cloud/img?hash=… URLs whose width/height are dropped on the ingest merge path, so galleries can jump as images load; review snippets must keep stripping reviewer PII exactly as HotelReviewExport does.

AIA-07

Turn an answer into a list from the action toolbar

Built
Corporate travel buyerMust
As a corporate travel buyer, I want to save the assistant's result set as a list in one click, so that a conversation becomes a working artefact I can enrich and export.
Acceptance criteria
  1. Given the assistant returned matches, when the action toolbar renders, then "Open in Hotels", "Save as dynamic list" and "Save as static list" are offered.
  2. Given I choose "Save as dynamic list", when it completes, then a list carrying the searchQuery is created via POST /search/save, a list_created artifact appears and "Open list: <name>" is offered.
  3. Given I choose "Save as static list", when it completes, then the selected hotel ids are frozen through POST /lists/fromIds and the list shows no Dynamic tag.
  4. Given I ask what lists I already have, when the reply renders, then a list_collection artifact lists them with per-list "Open list" actions.
  5. Given "Open in Hotels" is used, when navigation completes, then /search opens with the assistant's filters already applied and ai_assistant_hotels_opened is tracked.
  6. Given list creation fails, when the error returns, then a toast reports the failure and the artifact stays on screen so the action can be retried.
Implementation notes

The toolbar reuses ApiManager.saveList(filters) and saveListFromHotelIds(hotelIds, name?), i.e. exactly the same endpoints as the selection rail in HotelsSearch.vue; the list artifact renders the Open list button.

Edge cases & risks

These are write actions taken with no confirmation step, so a misread instruction can create lists (and, on paid tiers, subscription intent) the user did not ask for; a dynamic list saved from an over-broad AI filter can resolve to a very large hotel set.

AIA-08

Show live pricing inside a conversation

Partial
Hotel ownerShould
As a hotel owner, I want the assistant to answer "where am I being undercut this weekend" with actual offers, so that I can act without opening the metasearch screen.
Acceptance criteria
  1. Given I ask a price question for a property and dates, when the assistant answers, then a pricing artifact is rendered for the result.
  2. Given the organisation lacks metasearch access, when the price tool is invoked, then the assistant explains the 402 in plain language and points at Billing rather than failing silently.
  3. Given the metasearch allowance is exhausted, when the tool runs, then the metasearch_topup_required case is distinguished from a plain upgrade requirement.
  4. Given offers are returned, when the artifact renders, then the number of live offers is shown — gap: the artifact today surfaces only a live offer count, not the advertiser rows, per-night prices, refundability and "Cancel by" dates that MetasearchResults.vue renders.
Implementation notes

The pricing artifact type exists in views/AiAssistant.vue but is far thinner than the portal's own metasearch result rendering; the underlying data is GET /metasearch (FULL tier only, rate-limited, 2–5 s per call) proxied to METASEARCH_SERVICE_URL.

Edge cases & risks

Metasearch is the only rate-limited endpoint (100 requests / 60 s per principal), so a chatty assistant can exhaust a customer's own API budget; prices are point-in-time and the artifact carries no freshness stamp, inviting stale quoting.

AIA-09

See how much context and plan allowance a conversation is using

Built
Organisation adminShould
As an organisation admin, I want a visible meter of context and plan usage, so that I understand why long conversations degrade and how close we are to the limit.
Acceptance criteria
  1. Given the assistant is open, when the header renders, then an SVG ring shows the proportion of the context window used.
  2. Given I open the meter popover, when it renders, then it shows context window used / limit in tokens with a percentage, plan usage as a percentage, and a segmented bar with a legend for system prompt · MCP tools · messages.
  3. Given I am typing a long message, when the draft changes, then the meter adds a live estimate of the draft at roughly 4 characters per token before I send.
  4. Given GET /ai/assistant/limits fails, when the meter renders, then it degrades to a neutral state instead of showing a misleading zero.
  5. Given my draft would exceed the input limit, when I attempt to send, then a friendly over-limit message is shown rather than a raw server error.
Implementation notes

AiAssistantManager.state.contextUsage and state.usage are hydrated from api.getAiAssistantLimits()GET /ai/assistant/limits{ context, usage }; the manager includes explicit friendly error mapping for over-limit input.

Edge cases & risks

The 4-chars-per-token heuristic under-counts non-Latin scripts and long ids, so a draft that looks safe can still be rejected; "plan usage %" has no corresponding line in the Billing screens, so users cannot reconcile it against an invoice.

AIA-10

Escalate a conversation to a human

Built
Ubio support agentShould
As a Ubio support agent, I want users to hand a stuck conversation to support with its transcript attached, so that I can answer without asking them to reproduce the problem.
Acceptance criteria
  1. Given the assistant cannot resolve my problem, when I choose to contact support, then supportView moves from chat to escalate and a message form is shown.
  2. Given I submit the form, when it is sent, then POST /ai/assistant/contact receives my message, the conversation transcript and the current page URL, and supportView becomes done.
  3. Given the transcript exceeds 20,000 characters, when it is assembled, then it is truncated with a truncation prefix so the request still succeeds.
  4. Given the submission fails, when the error returns, then the form retains my text and offers a retry rather than dropping the message.
  5. Given escalation is started and submitted, when tracking fires, then ai_assistant_contact_started and ai_assistant_contact_submitted are recorded.
Implementation notes

api.supportContact(message, transcript, pageUrl); supportView is part of AiAssistantManager.state. Support identity elsewhere in the product is support@ubio.ai / hello@ubio.ai.

Edge cases & risks

Transcripts can contain a customer's uploaded hotel names and internal ids, so the escalation payload needs the same retention care as an export; there is no in-portal confirmation of what support received.

AIA-11

Keep a conversation across a page reload

Partial
Data analystShould
As a data analyst, I want my in-progress conversation to survive a refresh and to disappear when I switch organisation, so that I neither lose work nor leak one customer's context into another.
Acceptance criteria
  1. Given I have an open conversation, when I reload the tab, then messages, conversationId, artifacts and sidebar state are rehydrated from sessionStorage hotel-universe:ai-assistant-chat.
  2. Given the persisted payload was written by an older schema, when STORAGE_VERSION (currently 10) does not match, then the stored state is discarded rather than partially restored.
  3. Given I switch organisation or user, when the switch completes, then the persisted conversation state is reset so no cross-org context remains.
  4. Given sessionStorage is full or blocked, when a save fails, then the failure is swallowed and the conversation continues in memory.
  5. Gap: given I close the tab entirely, when I return later, then the in-progress conversation is gone from local state and only server-side history remains.
Implementation notes

AiAssistantManager persists through SessionStorageManager under hotel-universe:ai-assistant-chat with STORAGE_VERSION = 10; the org switch path in SettingsMenu.vue triggers a full window.location.reload().

Edge cases & risks

Version bumps silently destroy in-flight conversations for every user on deploy; sessionStorage quota is shared with hotel-universe:selected-hotels, which can hold up to 250 previewed ids from a virtual selection.

AIA-12

Retire or implement the AI recommendations screen

Partial
Ubio platform engineerCould
As a Ubio platform engineer, I want /ai/recommendations either backed by real data or removed, so that no customer can reach a screen made entirely of fixtures.
Acceptance criteria
  1. Given I navigate directly to /ai/recommendations, when the view renders, then it currently shows a hard-coded recommendations array with no API call — this is the defect.
  2. Given a decision to implement, when the screen loads, then recommendations are derived from real signals (the organisation's lists, recent hotels:view access logs and price insights) via a named endpoint.
  3. Given a decision to retire, when the route is removed, then the catch-all redirect returns the user to Home and no navigation entry points at it.
  4. Given the screen is kept but has no data for the organisation, when it renders, then an EmptyState is shown rather than fabricated hotels.
Implementation notes

views/AiRecommendations.vue is not linked from NavigationManager.getNavItems() and makes no ApiManager calls; it is effectively a hidden demo screen reachable only by URL.

Edge cases & risks

A prospect or auditor finding fixture hotels behind an authenticated route undermines trust in the rest of the data; leaving the route in place also leaves it in the router's tracked page_view events, polluting product analytics.

AIA-13

Let the assistant schedule recurring jobs

Proposed
Revenue managerShould
As a revenue manager, I want to say "watch this comp set weekly and tell me when someone undercuts me", so that monitoring happens without me re-asking every Monday.
Acceptance criteria
  1. Given a conversation that produced a comp set or list, when I ask the assistant to watch it on a cadence, then it proposes a named schedule with the target list, cadence and the condition to alert on, and asks me to confirm.
  2. Given I confirm, when the job is created, then it appears in a "Scheduled by the assistant" section I can pause, edit or delete without a conversation.
  3. Given a run finds a matching change, when it completes, then I receive a notification linking to the price matrix or list diff that triggered it.
  4. Given the organisation loses metasearch access or exhausts its allowance, when a run fires, then the job is suspended with a clear reason instead of consuming budget or failing silently every week.
  5. Given a scheduled job errors three times in a row, when the third failure is recorded, then the job is disabled and the owner is told.
Implementation notes

Would build on dynamic lists (searchQuery + GET /lists/:id/diff), the competitor price matrix (GET /hotels/:huId/competitor-price-matrix) and a job runner in the shape of PeriodicTaskRunner from packages/scrapers. Notifications would need a second SendGrid template — today sendInvitationEmail is the only one that exists.

Edge cases & risks

Scheduled metasearch calls multiply spend against a rate-limited, allowance-metered upstream; jobs owned by a user who is later removed from the organisation must be re-homed rather than orphaned.

AIA-14

Cite the records an answer came from

Proposed
Customer data engineerMust
As a customer data engineer, I want every factual claim in an answer to link to the hotel record, review or price snapshot it came from, so that I can verify it before I put it in front of my own users.
Acceptance criteria
  1. Given the assistant states a fact about a property, when the answer renders, then an inline citation links to the huId and the field group it came from.
  2. Given a claim came from a specific source, when I open the citation, then the source (GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia) and the field's provenance are shown.
  3. Given a price claim, when I open its citation, then the query parameters (check-in, nights, occupancy, currency, locale) and the snapshot time are shown.
  4. Given the assistant cannot attribute a statement to a record, when the answer renders, then that statement is marked as unsourced rather than presented with a fabricated citation.
Implementation notes

The data already exists: _dsNames records per-field provenance on canonicalData6 for refs / info / location / rating / features / images / rooms, and HotelInfoFieldConsensus already surfaces per-field agreement in HotelInfo.vue. Citations would reuse both.

Edge cases & risks

Canonical merge picks a winner per field group by DS_NAMES priority, so a citation may point at one source while a competing source disagrees; citing review text risks re-exposing the reviewer PII that exports deliberately strip.

AIA-15

Refuse and redirect out-of-scope requests

Proposed
Organisation adminMust
As an organisation admin, I want the assistant to refuse requests outside hotel data and rate intelligence, so that it does not give legal, contractual or booking advice on our behalf.
Acceptance criteria
  1. Given I ask something outside the product's scope, when the assistant responds, then it declines briefly and offers the nearest in-scope action.
  2. Given I ask it to make a booking or contact a hotel, when it responds, then it states that Hotel Universe surfaces rates but does not transact, echoing the landing disclaimer that direct rates "are the hotel's own room rates — not what Hotel Universe charges".
  3. Given I ask it to interpret a competitor's commercial terms or to price-fix, when it responds, then it refuses and logs the refusal category.
  4. Given a refusal fires on a legitimate in-scope question, when I rephrase, then the answer is produced — refusals must not be sticky for the conversation.
Implementation notes

Implemented as a system-prompt policy plus a server-side classifier on POST /ai/assistant/chat/stream, with refusal categories emitted to POST /portal-events alongside the existing ai_assistant_* events.

Edge cases & risks

Over-broad guardrails will block genuine revenue-management questions ("who is undercutting me") that are the product's core use case; refusal logs are themselves customer content and need the same retention rules as transcripts.

AIA-16

Preview a multi-step plan before the assistant writes anything

Proposed
OTA product managerMust
As an OTA product manager, I want to see and approve the plan before the assistant creates lists, subscribes hotels or spends metasearch allowance, so that one ambiguous sentence cannot cost money.
Acceptance criteria
  1. Given a prompt that implies writes or spend, when the assistant responds, then it renders an ordered plan (search → match → create list → pull prices → export) with the estimated hotel count and any billable step marked.
  2. Given a plan is shown, when I approve it, then the steps execute in order with per-step progress; when I reject it, then nothing is written.
  3. Given a step would create or modify a list, when the plan renders, then the target list name and whether it is dynamic or static are stated up front.
  4. Given a step would exceed the trial hotel limit or the metasearch allowance, when the plan renders, then that step is flagged as blocked before I approve.
  5. Given a step fails mid-plan, when execution stops, then completed steps are reported and no partial list is left in processing without explanation.
Implementation notes

Builds directly on the existing action toolbar and artifact types; the confirmation UX should match the $confirm.require dialogs already used for destructive list and direct-pricing actions.

Edge cases & risks

Plan previews add a round trip to every write, which will feel slow for trivial asks — the threshold for requiring approval needs tuning; list creation is asynchronous (created → processing → ready|error), so "approved" and "done" are not the same moment.

AIA-17

Use the customer's own lists as conversation context

Proposed
Corporate travel buyerShould
As a corporate travel buyer, I want the assistant to know about the lists I have already uploaded, so that I can say "check my Q3 programme list" instead of re-describing it.
Acceptance criteria
  1. Given my organisation has lists, when I refer to one by name, then the assistant resolves it and confirms which list it selected.
  2. Given a list is ambiguous by name, when the assistant responds, then it asks which of the candidates I meant rather than guessing.
  3. Given a list is still processing, when I reference it, then the assistant says so and offers to continue once it is ready.
  4. Given a list belongs to another organisation, when it is referenced, then it is not resolvable — cross-org access must return the same 403 the list endpoints already enforce.
  5. Given a list contains unresolved rows, when it is used as context, then the assistant states how many entries are unmatched, using GET /lists/:id/stats.
Implementation notes

Would extend the existing list_collection artifact into retrieval, backed by GET /lists, GET /lists/:id/entries and GET /lists/:id/stats; the manager's "workspace memory" framing from the MCP epic applies here too.

Edge cases & risks

Large lists cannot fit in the context window, so summarisation is required and will lose detail; injecting customer CSV content into a prompt is an injection surface — uploaded hotel names must be treated as data, never as instructions.

AIA-18

Dictate a query by voice

Proposed
Hotel ownerCould
As a hotel owner, I want to speak my question instead of typing it, so that I can check rates from a phone while walking the property.
Acceptance criteria
  1. Given a browser with microphone support, when I press the mic button in the composer, then speech is transcribed into the textarea for review before sending.
  2. Given I stop dictating, when transcription completes, then I can edit the text and press Enter to send exactly as with typed input.
  3. Given permission is denied or the browser has no speech support, when I press the mic, then the button is disabled with an explanatory tooltip and typing still works.
  4. Given transcription produces an obviously empty result, when I release, then no message is sent.
Implementation notes

A composer-level addition to views/AiAssistant.vue; note that the onboarding tour is already skipped below 992 px, so the mobile assistant layout would need work alongside this.

Edge cases & risks

Hotel and place names transcribe badly, which will push bad queries into POST /search/ai; sending audio to a third-party transcription service introduces a new data-processing relationship customers must be told about.

AIA-19

Share a conversation by link

Proposed
Travel agency consultantCould
As a travel agency consultant, I want to send a colleague a link to a conversation and its resulting list, so that the reasoning travels with the output.
Acceptance criteria
  1. Given a conversation, when I choose Share, then a link is generated that is readable by other members of my organisation.
  2. Given a recipient outside the organisation opens the link, when authorisation is checked, then access is refused — sharing is org-scoped, matching the cross-org 403 on lists.
  3. Given the conversation is later deleted, when the link is opened, then it reports that the conversation is no longer available.
  4. Given a shared conversation contains artifacts referencing hotels the viewer's tier cannot open, when they click through, then the normal upgrade path is shown.
Implementation notes

Would extend GET /ai/assistant/conversations/:id with a share token and a read-only rendering mode of views/AiAssistant.vue; membership is organizations.memberIds, and there are no per-user roles to complicate the check.

Edge cases & risks

Shared transcripts are a quiet data-export channel — anything the assistant said about a customer's supply file becomes forwardable; revocation must be immediate, not TTL-based.

AIA-20

Customise the assistant per organisation

Proposed
OTA supply managerCould
As an OTA supply manager, I want to tell the assistant our house conventions — preferred currency, star-rating floor, brands we never list — so that its answers match our commercial rules by default.
Acceptance criteria
  1. Given I am an organisation member, when I open assistant settings, then I can save a short instruction block and defaults for currency, locale and rate type.
  2. Given defaults are saved, when the assistant runs a price tool without explicit parameters, then it uses them instead of the platform defaults (currency GBP, locale us in the API, gb in the portal filter).
  3. Given custom instructions conflict with a platform guardrail, when a request is handled, then the guardrail wins and the conflict is reported.
  4. Given the instruction block exceeds its size limit, when I save, then the save is rejected with the limit stated rather than silently truncated.
Implementation notes

Stored on the organizations document alongside tier and billing, injected as a prepended system-prompt segment; the context meter's "system prompt" segment already exists to show the cost of doing this.

Edge cases & risks

With no per-user roles, any member can rewrite the assistant's behaviour for everyone in the organisation; long instruction blocks eat the context window the meter is already warning about.

AIA-21

Score assistant answer quality with an evaluation harness

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want a repeatable evaluation suite over representative prompts, so that a prompt or model change cannot silently regress answer quality.
Acceptance criteria
  1. Given a fixed prompt set covering the four landing use-case "Typical ask" queries, when the suite runs, then each answer is scored on tool selection, filter correctness and citation presence.
  2. Given a change to the system prompt or model, when CI runs the suite, then a scorecard is produced and a regression beyond the threshold fails the build.
  3. Given a prompt exercises a paid tool, when the suite runs, then it uses fixtures rather than live metasearch calls.
  4. Given a scored answer contains a hallucinated huId or endpoint, when it is graded, then it is marked as a hard failure regardless of other scores.
Implementation notes

Would sit alongside the existing unit suites (GeneratingSummaryPoller.test.ts in the portal; RateLimitService, AccessTierService, HuId and parser tests in the API) and run in the same GitHub Actions pipeline.

Edge cases & risks

Model non-determinism makes thresholds noisy, so the harness needs repeated sampling; fixture drift against the real catalogue will produce false failures as canonical data changes.

AIA-22

Attribute assistant cost and usage per conversation

Proposed
Ubio finance opsShould
As Ubio finance ops, I want token and tool cost attributed per conversation, organisation and plan, so that we can price the assistant instead of absorbing it.
Acceptance criteria
  1. Given a conversation completes, when usage is recorded, then input/output tokens, tool calls and any billable metasearch calls are attributed to the organisation.
  2. Given I open an internal usage view, when I filter by organisation and date range, then per-conversation totals and a plan-usage percentage are shown.
  3. Given an organisation crosses a defined threshold, when the next message is sent, then the user sees the same friendly over-limit treatment the manager already implements for oversized input.
  4. Given usage recording fails, when the conversation completes, then the failure is logged and the answer is still delivered — metering must never block a reply.
Implementation notes

Extends the accessLogs time-series pattern ({time, principalId, huId, meta:{orgId, action}}, TTL 1 year) with assistant actions, and feeds the "plan usage %" already shown by GET /ai/assistant/limits and the context meter.

Edge cases & risks

accessLogs is a hotel-keyed time-series collection, so assistant events without a huId need a different shape or a separate collection; no usage is reported to Stripe today, so attribution stays internal until metered billing exists.

AIA-23

Degrade gracefully when the model is unavailable

Proposed
Trial userMust
As a trial user, I want the search page to keep working when the assistant is down, so that an outage in one dependency does not take the whole product with it.
Acceptance criteria
  1. Given the model provider is unavailable, when I send a message, then a clear "the assistant is temporarily unavailable" state is shown with a retry action.
  2. Given the assistant is unavailable and I am in assistant search mode, when the failure is detected, then the portal offers a one-click switch to "Build with filters" and the filter bar works normally.
  3. Given POST /search/ai fails, when I submit a natural-language query, then the "Search name '<text>'" fallback runs the query as a plain name search rather than returning nothing.
  4. Given the provider recovers, when I retry, then the existing conversation continues with its history intact rather than starting a new conversationId.
  5. Given repeated failures, when the threshold is crossed, then the failure is reported to POST /portal-events so the outage is visible in product analytics.
Implementation notes

Builds on the existing error state in AiAssistantManager.state.error and the mode toggle in views/HotelsSearch.vue; the footer's "Status: all systems green" claim on the landing page should be driven by the same signal.

Edge cases & risks

Because the assistant is the default search mode, an outage looks like a total product outage to new users; a hard failover to filter mode must not overwrite the user's persisted hotel-universe:search-mode preference permanently.

MCP 1 built11 partial12 proposed

MCP server & agentic access

The landing page sells Hotel Universe as agent-ready: an MCP server installed with npx @hoteluniverse/mcp, six tools, scoped read-only keys, streaming results and workspace memory. What is demonstrably in the repositories is the marketing surface, the setup documentation and the MCP connection card in the assistant sidebar — the server package itself is not in the API monorepo clone, and the token model behind HU_API_KEY cannot currently issue a read-only key. This epic covers making the advertised surface real and then hardening it for autonomous consumers: scopes, quotas, errors, idempotency, pagination and attribution.

MCP-01

Install the MCP server from a config file

Partial
AI agent builderMust
As an AI agent builder, I want to add Hotel Universe to my MCP client by pasting a config block, so that I get hotel tools without building an ingest pipeline first.
Acceptance criteria
  1. Given the landing page MCP section, when I copy the sample, then I receive a claude_desktop_config.json block registering hotel-universe with command: "npx", args: ["@hoteluniverse/mcp"] and an env containing HU_API_KEY.
  2. Given I paste it into a supported client, when the client starts, then the server is launched and its tools are listed.
  3. Given the package cannot be fetched or the client is offline, when startup fails, then the failure names the package rather than surfacing a bare exit code.
  4. Gap: given I look for the server implementation, when I search the hotel-universe-api monorepo (packages api, docs, img, import, link, scrapers, shared), then there is no MCP package — the advertised server is not in the clone and its versioning and release process are undocumented.
Implementation notes

Copy and config sample are rendered by components/landing/McpShowcase.vue / McpPanel.vue; the client pills advertise Claude, ChatGPT and Cursor.

Edge cases & risks

An npx-launched local server means every customer runs an unpinned version, so a bad publish reaches all agents at once; the advertised env var HU_API_KEY (sample value hu_•••••••) does not match the platform's real token prefix pat_, which will produce confusing first-run failures.

MCP-02

Authenticate the MCP server with a platform token

Partial
DeveloperMust
As a developer, I want the MCP server to authenticate with a token I generate in the portal, so that agent access is tied to my organisation and revocable.
Acceptance criteria
  1. Given I create a personal access token at /tokens, when I copy the secret, then it can be used as HU_API_KEY and the server authenticates as Authorization: Bearer pat_….
  2. Given the token is valid, when a tool runs, then the principal resolves to serviceAccount with displayName: "PAT <name>" and the organisation and scopes come from the accessTokens record.
  3. Given the token is deleted in the portal, when the next tool call is made, then it fails with an authentication error and the agent is told to re-issue a key.
  4. Gap: given the documentation uses HU_API_KEY with an hu_ prefix while the platform issues pat_ secrets, when a user follows the sample literally, then it is unclear which value is expected.
Implementation notes

pat_ secrets are looked up by SHA-256 hash in accessTokens; PATs never expire. Portal creation path is POST /tokens from views/AccessTokens.vue.

Edge cases & risks

A key handed to an agent is a long-lived, non-expiring org credential in a local config file; because PATs carry lists:manage and exports:manage, a leaked key can create lists and generate exports, not just read.

MCP-03

Issue scoped read-only keys per agent or workspace

Partial
Organisation adminMust
As an organisation admin, I want to issue a read-only key per agent or per workspace, so that an autonomous consumer cannot mutate our lists.
Acceptance criteria
  1. Given I create a token, when I name it, then the name identifies the agent or workspace and appears as PAT <name> in access records.
  2. Given a token is issued for an agent, when I no longer trust it, then deleting it at /tokens immediately revokes that agent's access without affecting other keys.
  3. Gap: given the landing page promises "Scoped API keys — issue read-only tokens per agent or per workspace", when I create a token today, then it always receives the fixed PAT scope set exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view — there is no read-only option and no scope picker.
  4. Given a read-only key exists in future, when it calls a mutating tool such as lists.create, then the call is refused with a scope error naming the missing permission.
Implementation notes

Scopes are stored per token on accessTokens.scopes[] but are set from a fixed list at creation; orgs:manage and pat:manage are correctly excluded, and users:manage is declared but never enforced anywhere.

Edge cases & risks

Every organisation member sees identical scopes — there are no per-user roles — so "per agent" isolation today is naming convention only; the invitation endpoints being gated by LISTS_MANAGE is an existing mis-scoping that shows how easily scope drift happens.

MCP-04

Search hotels from an agent

Partial
AI agentMust
As an AI agent, I want a hotels.search tool that finds properties by query, geography and amenity, so that I can answer supply questions without scraping.
Acceptance criteria
  1. Given a text query, when hotels.search runs, then it returns matching hotels with huId, name, city, country, star rating and user rating.
  2. Given lat, lon and radius, when the tool runs, then results are constrained to that circle and ordered by review volume.
  3. Given amenity terms, when the tool runs, then they are matched fuzzily against features.title.
  4. Given no usable criteria at all, when the tool runs, then it returns an explicit "no criteria supplied" error rather than an empty result set.
  5. Given more results exist, when the response is returned, then a continuation token is included.
Implementation notes

Maps onto GET /search (HOTELS_VIEW) with the shared filter set (lat, lon, radius 1–10,000,000 m, name, country[], city, stars[], features[] fuzzy maxEdits 2, hotelIdType, hotelId, has* flags) and pageToken, backed by the Atlas Search index atlas_custom_search_10, page size 10.

Edge cases & risks

A query with no clauses degenerates to $match {_id: 'impossible-id'} and returns nothing — an agent will read that as "this hotel does not exist"; page size 10 means a broad search costs many round trips of context.

MCP-05

Fetch a full hotel record from an agent

Partial
AI agentMust
As an AI agent, I want a hotels.get tool returning the full canonical record and photos, so that I can render a property without stitching sources.
Acceptance criteria
  1. Given a huId, when hotels.get runs, then it returns info, location, rating, features, images and rooms as held on canonicalData6.
  2. Given the organisation is on trial and has exhausted its hotel limit, when the tool runs, then it returns the 402 condition with the message "Upgrade to view. Contact sales at hello@ubio.ai" mapped to a machine-readable error.
  3. Given the hotel exists only as a preview for that organisation, when the tool runs, then the preview shape is returned and the response states that fields are withheld.
  4. Given an unknown huId, when the tool runs, then a not-found error is returned rather than an empty record.
Implementation notes

Maps onto GET /hotels/{huId} (HOTELS_VIEW), which logs hotels:view to accessLogs; the trial quota is ACCESS_TIER_TRIAL_LIMIT (default 100 distinct hotels, all-time).

Edge cases & risks

Every agent fetch burns a distinct-hotel allowance permanently, so an exploratory agent loop can exhaust a trial in minutes; image URLs are AES-encoded images.automation.cloud links whose dimensions are dropped on one ingest path.

MCP-06

Summarise reviews from an agent

Partial
AI agent builderShould
As an AI agent builder, I want a reviews.summarise tool returning themed sentiment, so that my agent can explain a property's reputation without ingesting raw reviews.
Acceptance criteria
  1. Given a huId, when reviews.summarise runs, then it returns a summary with positive and negative themes and per-aspect sentiment.
  2. Given the organisation is on TRIAL, when the tool runs, then it returns the tier error — GET /hotels/{huId}/reviews is 402 on trial.
  3. Given the summary is still being generated, when the tool runs, then it returns a generating state the agent can poll rather than blocking indefinitely.
  4. Given the hotel has no reviews, when the tool runs, then it returns an explicit empty-with-reason result.
  5. Given a summary is returned, when it is serialised, then no reviewer name or other PII is included.
Implementation notes

Maps onto GET /hotels/:id/reviews/summary (portal HotelReviewSummaryManager) with the GeneratingSummaryPoller semantics — 3 s interval, MAX_POLLS = 20, statuses ready / generating / error; HotelReviewExport already strips reviewer.

Edge cases & risks

A 20-poll ceiling means a cold summary can time out into an error for an agent that would happily have waited; rating scales differ by source (Google 5, Booking 10), so an unnormalised score in a summary is misleading.

MCP-07

Compare OTA prices from an agent

Partial
AI agentMust
As an AI agent, I want a prices.metasearch tool comparing OTA offers against direct, so that I can answer "what does this cost today" in one call.
Acceptance criteria
  1. Given a hotel id, check-in date, nights and occupancy, when prices.metasearch runs, then provider offers are returned with advertiser, total price and per-night price.
  2. Given rateType, currency, locale and deviceType, when supplied, then they are honoured, defaulting to cheapest and GBP.
  3. Given the organisation is not on FULL tier, when the tool runs, then a 402 is mapped to an upgrade error naming the required tier.
  4. Given the per-principal rate limit is exceeded, when the tool runs, then a rate-limit error carrying Retry-After is returned so the agent can back off rather than hammer.
  5. Given only a non-supported id type is provided, when the tool runs, then it errors — metasearch accepts huId and googleHotelId only.
Implementation notes

Maps onto GET /metasearch, proxied to METASEARCH_SERVICE_URL, logging metasearch:view. This is the only endpoint the rate limiter is applied to (RATE_LIMIT_REQUESTS 100 / RATE_LIMIT_DURATION_SECONDS 60, Redis-backed when REDIS_URL is set).

Edge cases & risks

Documented response time is 2–5 s, which is long for a synchronous tool call inside an agent loop; children's age is fixed at 10 and there is no location search, so an agent must resolve a hotel before it can price it.

MCP-08

Pull direct booking-engine rates from an agent

Partial
Revenue managerShould
As a revenue manager, I want a prices.ari tool returning the hotel's own booking-engine rates, so that my agent can compare direct against OTA in the same answer.
Acceptance criteria
  1. Given a huId with direct pricing enabled, when prices.ari runs, then daily rates are returned with availability, rate plan, board and cancellation terms over the requested window.
  2. Given the hotel is not linked to a booking engine, when the tool runs, then it returns a "not enabled for direct pricing" error naming the enable action.
  3. Given a refresh is requested and the organisation lacks refresh rights, when the tool runs, then it is refused — refresh is FULL tier only.
  4. Given a lookahead beyond the supported horizon is requested, when the tool runs, then it is clamped to the maximum and the clamp is reported in the response.
Implementation notes

Maps onto GET /direct-pricing/prices/:huId and POST /direct-pricing/prices/:huId/refresh (batch id + GET /direct-pricing/prices/batch/:batchId), as used by DirectPricesDetail.vue; refresh access is AccessManager.hasDirectPricingRefreshAccess() (full tier only). Landing copy promises up to a 365-day lookahead.

Edge cases & risks

Refresh is asynchronous with a polling batch, which does not fit a single synchronous tool call; linking a hotel to a booking engine can still require the ubio-internal manual IBE matching screens, so coverage is uneven and an agent cannot fix it.

MCP-09

Create and export lists from an agent

Partial
AI agent builderMust
As an AI agent builder, I want lists.create and lists.export tools, so that my agent can turn a search into a durable, exportable artefact.
Acceptance criteria
  1. Given a set of hotel ids, when lists.create runs, then a list is created and its id, name and status are returned.
  2. Given a saved filter set instead of ids, when lists.create runs, then a dynamic list carrying the searchQuery is created.
  3. Given a list id and an export type, when lists.export runs, then an export job is created and a job id is returned; polling yields 200 with a downloadUrl, 202 while processing, or 410 once expired.
  4. Given the organisation is on trial, when a full or reviews export is requested, then the 100-hotel trial export limit is stated in the response.
  5. Given an invalid export type, when the tool runs, then it is rejected against the allowed set full | preview | reviews.
Implementation notes

Maps onto POST /lists/fromIds, POST /search/save, POST /lists/{listId}/exports and GET /lists/{listId}/exports/{exportId}/download; exports stream gzipped JSON to GCS with a signed URL valid 15 minutes and expiresAt = now + 30 days.

Edge cases & risks

A 15-minute signed URL is short for an agent that queues downloads; list creation is asynchronous (created → processing → ready|error), so an agent that assumes synchronous success will read an empty list.

MCP-10

Stream partial results for long-running searches

Partial
AI agentShould
As an AI agent, I want long-running searches to surface partial state as it arrives, so that I can start reasoning before the full result set lands.
Acceptance criteria
  1. Given a broad hotels.search, when results are produced, then the first page is emitted as soon as it is available rather than after the whole set.
  2. Given a multi-step tool chain, when each step completes, then a progress event names the step, mirroring the assistant's statusText behaviour.
  3. Given the client does not support streaming, when the tool runs, then it falls back to a single buffered response.
  4. Given the stream aborts, when the failure occurs, then the partial results already delivered are marked incomplete so the agent does not treat them as the full set.
Implementation notes

Advertised on the landing page as "Streaming results — long-running searches surface partial state instantly". The nearest existing implementation is POST /ai/assistant/chat/stream with its onStatus / onDelta / onDone events; the search repo already exposes an unbounded stream async generator used for saved searches.

Edge cases & risks

Partial results plus a searchAfter-based cursor make deduplication the agent's problem; an aborted stream leaves no server-side record that the agent saw only half the set.

MCP-11

Remember the workspace's active lists and price queries

Partial
AI agent builderShould
As an AI agent builder, I want the server to remember the workspace's active lists and recent price queries, so that follow-up prompts do not have to repeat every identifier.
Acceptance criteria
  1. Given I created a list through lists.create, when a later tool call refers to "the list", then the server resolves it to the most recent list in that workspace.
  2. Given I ran a price query, when I ask for the same dates against another property, then the previous parameters (nights, occupancy, currency, locale) are reused unless overridden.
  3. Given memory would resolve ambiguously, when a reference is made, then the server asks for disambiguation rather than guessing.
  4. Given a new workspace or a new key, when tools run, then no memory from another workspace is visible.
  5. Given the memory is stale — for example the list was deleted — when it is referenced, then it is dropped and the agent is told.
Implementation notes

Advertised as "Workspace memory — the assistant remembers your active lists and price queries"; the portal's analogue is AiAssistantManager session state keyed to org and user, and DirectPricingManager's localStorage batch memory with a 24 h TTL.

Edge cases & risks

Memory scoped to a key rather than a user blurs the audit trail — actions attributed to PAT <name> cannot be traced to a person; stale memory silently changing which list an agent writes to is a data-integrity risk.

MCP-12

Find setup instructions from inside the product

Built
DeveloperShould
As a developer, I want a route from the assistant to MCP setup instructions, so that I can wire the same tools into my own client without hunting.
Acceptance criteria
  1. Given I open /ai/assistant, when the left rail renders, then an MCP connection card is shown.
  2. Given I click the card, when navigation happens, then https://docs.hotel-universe.travel/guide/mcp-setup.html opens.
  3. Given I am on the landing page, when I use the "Install MCP server" CTA, then I reach the same setup material.
  4. Given the docs host is unreachable, when the link is followed, then the failure is the browser's, not a broken in-app state — the card must not depend on a runtime fetch.
Implementation notes

The card lives in the views/AiAssistant.vue sidebar; NavigationManager also exposes an external Docs item to https://docs.hotel-universe.travel/guide/, and components/landing/links.ts holds DOCS_URL and the product doc URLs.

Edge cases & risks

The setup page is outside both repositories, so it can drift from the shipped tool set with nothing to catch it; the card is only reachable from the assistant, so agent builders who never open the chat may miss it.

MCP-13

Offer a remote OAuth MCP server

Proposed
AI agent builderMust
As an AI agent builder, I want to connect to a hosted Hotel Universe MCP endpoint with OAuth, so that I do not have to place a long-lived API key in a local config file.
Acceptance criteria
  1. Given a supported client, when I add the remote server URL, then I complete an OAuth authorisation and the client stores a short-lived token.
  2. Given authorisation succeeds, when tools are listed, then the same six tools are available as with the local npx server.
  3. Given my token expires, when the next call is made, then it is refreshed transparently without user action.
  4. Given I revoke the grant, when the next call is made, then it fails with an authorisation error and no cached credential works.
  5. Given an organisation has multiple memberships, when I authorise, then I select which organisation the connection acts for, mirroring POST /auth/switch-org.
Implementation notes

Would build on the existing Google-OAuth login and JWT issuance (JWT_ISSUER, JWT_TOKEN_EXPIRATION_SECONDS 604800) rather than the never-expiring pat_ path, and would remove the version-pinning problem of npx @hoteluniverse/mcp.

Edge cases & risks

A hosted server becomes a single availability dependency for every customer agent; org selection at authorisation time is easy to get wrong when a user belongs to several organisations.

MCP-14

Put per-tool scopes and spend caps on a key

Proposed
Organisation adminMust
As an organisation admin, I want to grant an agent key only specific tools and a monetary or volume cap, so that an unattended loop cannot spend our metasearch allowance.
Acceptance criteria
  1. Given I create a key, when I configure it, then I can enable individual tools (for example hotels.search and hotels.get only) rather than the whole set.
  2. Given a cap is set, when cumulative billable calls reach it, then further billable tool calls are refused with a cap-reached error while read-only tools continue to work.
  3. Given a cap is reached, when I inspect the key, then I see what consumed it, broken down by tool.
  4. Given a key with no cap, when it is created, then the organisation default cap applies rather than "unlimited" by omission.
  5. Given a cap is raised, when the next call is made, then it succeeds immediately without re-issuing the key.
Implementation notes

Extends accessTokens beyond scopes[] with per-tool grants and counters, enforced at the same middleware layer as the existing permissions (hotels:view, reviews:view, metasearch:view, lists:manage, exports:manage).

Edge cases & risks

Caps counted from accessLogs are eventually consistent, so a burst can overshoot; per-tool grants multiply the permission matrix in a system that has no per-user roles at all today.

MCP-15

Surface rate limits and quotas to the agent

Proposed
AI agentMust
As an AI agent, I want to know my remaining quota and the reset time on every response, so that I can pace myself instead of discovering limits by failing.
Acceptance criteria
  1. Given any tool call, when it returns, then the response carries remaining requests, the limit and the window reset time.
  2. Given I exceed the limit, when the call is refused, then the error includes Retry-After and is distinguishable from an authorisation failure.
  3. Given I am near a tier quota such as the trial hotel limit, when a tool returns, then a warning field states how much headroom remains.
  4. Given quotas differ per tool, when limits are reported, then they are reported per tool, not as one global number.
  5. Given the limiter's Redis backend is unavailable, when calls continue, then the in-memory fallback is used and the response marks the limit figures as approximate.
Implementation notes

The limiter is rate-limiter-flexible with Redis when REDIS_URL is set (else in-memory), keyed by principal, defaults 100 / 60 s — but is applied only to GET /metasearch, so exposing per-tool numbers requires extending it first.

Edge cases & risks

An in-memory fallback across multiple API instances produces limits that vary by which pod answers; publishing exact quotas also publishes exactly how to saturate them.

MCP-16

Return a structured error taxonomy agents can branch on

Proposed
AI agent builderMust
As an AI agent builder, I want stable machine-readable error codes, so that my agent can retry, upgrade or give up deterministically instead of parsing prose.
Acceptance criteria
  1. Given any tool failure, when it returns, then it carries a stable code, a category (auth, permission, quota, tier, validation, not-found, upstream, internal) and a human message.
  2. Given a tier restriction, when it returns, then the code distinguishes an upgrade requirement from the metasearch top-up case (metasearch_topup_required).
  3. Given a validation failure, when it returns, then the offending field and the expected shape are named — as UploadListError already does with expected/received detail.
  4. Given a transient upstream failure, when it returns, then it is marked retryable with a suggested backoff; permanent failures are marked non-retryable.
  5. Given a new error code is introduced, when it reaches an older client, then the category still allows sensible handling.
Implementation notes

Would formalise the codes already implied across the API — 402 upgrade, 403 cross-org list access, 406 CSV upload, 410 expired export, 429 rate limit — into a documented taxonomy published alongside the tool schemas.

Edge cases & risks

Today's generic strings ("Export failed. Please try again later or contact support if the problem persists.") give an agent nothing to branch on; once codes are published they become an API contract that cannot be renamed casually.

MCP-17

Accept idempotency keys on list-mutating tools

Proposed
AI agentShould
As an AI agent, I want to pass an idempotency key when creating or modifying a list, so that a retry after a timeout does not create a duplicate.
Acceptance criteria
  1. Given lists.create is called with an idempotency key, when the same key is replayed within the retention window, then the original list is returned rather than a new one.
  2. Given the same key is replayed with a different payload, when the call is made, then it is rejected as a conflict.
  3. Given no key is supplied, when the call is made, then current behaviour is preserved for compatibility.
  4. Given the key's retention window has passed, when it is replayed, then a new list is created and the response says the key was not recognised.
Implementation notes

Applies to POST /lists/fromIds, POST /search/save, POST /lists/combine, POST /lists/:id/entries and POST /lists/:id/exports; keys would be stored beside the lists and exports documents with a TTL.

Edge cases & risks

List creation is fire-and-forget through PendingPromises, so the response can return before the list is ready — the idempotency record must be written at request time, not completion; agents that generate a fresh key per retry defeat the mechanism entirely.

MCP-18

Offer a dry-run mode for mutating tools

Proposed
AI agent builderShould
As an AI agent builder, I want to run a mutating tool in dry-run mode, so that I can show the user what would happen before anything is written or billed.
Acceptance criteria
  1. Given lists.create is called with dry-run set, when it returns, then it reports the list name, the resolved hotel count and whether the list would be dynamic or static — and nothing is persisted.
  2. Given a dry-run export, when it returns, then it reports the export type, estimated row count and whether the trial limit of 100 hotels would truncate it.
  3. Given a dry-run price call, when it returns, then it reports how many billable calls the real run would make.
  4. Given dry-run is requested on a tool that has no side effects, when it runs, then the request is accepted and behaves normally rather than erroring.
Implementation notes

Would reuse the counting paths already present — GET /lists/:id/stats ({total, hotels}), the search $searchMeta total with its 1000 threshold, and deriveRichContentUsage / computeRichContentOverLimit for subscription cost estimates.

Edge cases & risks

Estimated counts from $searchMeta are capped and can be wrong above the threshold, so a dry-run figure may understate a large list; a dry-run that itself costs an upstream metasearch call would defeat its purpose.

MCP-19

Paginate tool results for a context window

Proposed
AI agentMust
As an AI agent, I want tool results returned in cursor-paged chunks sized for my context window, so that one broad search does not blow my budget.
Acceptance criteria
  1. Given a result set larger than one page, when the tool returns, then it includes an opaque cursor and the total (or an explicit "more than N" marker).
  2. Given I supply a page size, when it exceeds the maximum, then it is clamped and the clamp is reported.
  3. Given I request a field subset, when the tool returns, then only those fields are serialised, so an agent can trade detail for breadth.
  4. Given a stale or malformed cursor, when it is supplied, then a validation error is returned rather than silently restarting from page one.
  5. Given the result set changed between pages, when the next page is fetched, then the response flags that the underlying set moved.
Implementation notes

The API already paginates search with Atlas searchAfter plus a searchSequenceToken (fetch 11, return 10) and lists with page size 10; MCP paging would wrap those cursors and add response-size awareness on top.

Edge cases & risks

Fixed page size 10 means high per-item overhead for agents; because search ordering is by rating.numberOfReviews descending with no stable tiebreak documented, cursor stability under concurrent ingest is not guaranteed.

MCP-20

Resolve hotels from names and addresses without a CSV

Proposed
Customer data engineerMust
As a customer data engineer, I want a hotels.match tool that resolves a batch of names and addresses to canonical ids, so that my agent can do entity resolution without uploading a file.
Acceptance criteria
  1. Given a batch of records with name plus at least one of street address, city, postal code or country, and optionally coordinates, when hotels.match runs, then each record returns a candidate huId with a confidence score.
  2. Given a record scores at or above the acceptance threshold, when it returns, then it is marked confirmed; below the threshold it is marked needs_review.
  3. Given a record has no usable matching data, when it returns, then it is marked unmatched with a reason from the existing set (missing_matching_data, id_lookup_not_found, no_candidates_above_threshold, all_candidates_rejected).
  4. Given a batch exceeding the size limit, when it is submitted, then it is rejected with the limit stated rather than partially processed.
  5. Given per-signal detail is requested, when results return, then name, address, postal-code and geo sub-scores are included.
Implementation notes

Would expose the packages/link pipeline as a tool: the refs ladder, then findHuIdByGooglePlaceId via Google Places Text Search with locationBias.circle radius 1000 and includedType: lodging, scored as nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accepted at ≥ 0.7 — the same scoring ListReviewQueue.vue renders.

Edge cases & risks

The matcher is documented as poor at distinguishing "123 Main St" from "124 Main St", so confident-looking matches can be wrong; each match may trigger a paid Google Places call, so batch size and caching matter commercially.

MCP-21

Give agents list diffing and price watching

Proposed
AI agent builderShould
As an AI agent builder, I want lists.diff and prices.watch tools, so that my agent can act on change rather than re-reading everything.
Acceptance criteria
  1. Given a dynamic list, when lists.diff runs, then it returns the hotels now matching the saved query that are not yet in the list, with a count.
  2. Given a diff result, when I add a subset, then the tool reports { added, skipped } for the operation.
  3. Given lists.diff is called on a static list, when it runs, then it returns an explicit "list is not dynamic" error.
  4. Given prices.watch is registered for a hotel, dates and a threshold, when a price crosses the threshold, then the agent receives a change event with old and new values.
  5. Given a watch would exceed the organisation's metasearch allowance, when it is registered, then registration is refused with the allowance stated.
Implementation notes

lists.diff maps directly onto the existing GET /lists/:id/diff and POST /lists/:id/entries used by ListDiffManager; prices.watch has no server-side equivalent and would need the scheduled-job substrate also required by the assistant's watch feature.

Edge cases & risks

Diffs on very large dynamic lists are already truncated in the portal with a truncation message, so an agent must handle incomplete diffs; a watch is a standing cost commitment that outlives the conversation that created it.

MCP-22

Attribute usage to an agent identity

Proposed
Ubio finance opsShould
As Ubio finance ops, I want every tool call attributed to the agent identity that made it, so that we can bill and diagnose per agent rather than per organisation.
Acceptance criteria
  1. Given a tool call authenticated by a key, when it is logged, then the record carries the key's name, the organisation and the tool invoked.
  2. Given I open the Usage screen, when I filter by agent, then per-agent counts are shown alongside the existing action descriptions.
  3. Given a key is deleted, when historical usage is queried, then past attribution is preserved under the key's name.
  4. Given two keys share a name, when usage is reported, then they remain distinguishable by token id.
Implementation notes

Extends accessLogs ({time, principalId, huId, meta:{orgId, action}}) with the token id, and GET /stats/ plus UsageTable.vue, which today maps only lists:create, metasearch:view, hotels:view, reviews:view and the six export actions.

Edge cases & risks

accessLogs counts distinct hotels per action, not calls, so it cannot answer "how many tool calls" without a schema change; the time-series TTL of one year sets a hard ceiling on retrospective billing disputes.

MCP-23

Give agent builders a sandbox with sample data

Proposed
DeveloperShould
As a developer, I want a sandbox key and a small fixture catalogue, so that I can build and test an agent before buying a plan.
Acceptance criteria
  1. Given I request sandbox access, when it is granted, then I receive a key that reaches a sandbox environment with a fixed set of fixture hotels.
  2. Given I call any of the six tools against the sandbox, when they return, then responses are shaped identically to production, including error shapes.
  3. Given I call a priced tool in the sandbox, when it returns, then deterministic fixture prices are returned and nothing is billed.
  4. Given a sandbox key is used against production, when the call is made, then it is refused with an environment-mismatch error.
  5. Given sandbox data is refreshed, when fixtures change, then the change is announced in the API changelog.
Implementation notes

Would reuse the integration-test fixtures that already exist for auth, users, organisation, invitation, tokens, lists, search, export, metasearch and sources, served from a separate deployment of the same images (hotel-universe-api, hotel-universe-scraping-service).

Edge cases & risks

Fixture catalogues drift from production semantics, teaching agents to rely on behaviour that does not hold live; a sandbox that is too generous becomes a way to avoid paying for real data.

MCP-24

Publish tool schemas and list in agent marketplaces

Proposed
AI agent builderCould
As an AI agent builder, I want published JSON-schema definitions for every tool and a listing in the major agent directories, so that I can generate typed bindings and discover the server without reading marketing copy.
Acceptance criteria
  1. Given the docs site, when I open the MCP reference, then each of the six tools has a JSON-schema input and output definition with descriptions and examples.
  2. Given a schema changes, when a release is published, then the version and the change appear in the API changelog with a deprecation window for removals.
  3. Given a marketplace listing, when a user finds it, then it links to setup, the schema reference and the sandbox.
  4. Given a tool is deprecated, when its schema is served, then it is marked deprecated with a named replacement rather than removed without notice.
Implementation notes

Would be generated by the same docs package that produces the OpenAPI spec (OpenApiSpecBuilder, JsonSchemaBuilder), and published beside https://docs.hotel-universe.travel/guide/mcp-setup.html.

Edge cases & risks

The existing generated artefact is already stale in the repository (docs/openapi.json at v0.27.2 against an API at v0.33.2), so publishing schemas without CI enforcement will repeat that failure; a marketplace listing raises the cost of any breaking change.

DEV 4 built6 partial14 proposed

Public API, keys, docs & developer experience

Hotel Universe ships a REST API at https://api.hotel-universe.travel with Bearer authentication, personal access tokens issued from the portal, and a generated OpenAPI specification. The foundations are real, but the developer surface has visible gaps: the rate limiter guards only one endpoint, the committed OpenAPI file is several minor versions stale and documents no security schemes, docs.http describes an endpoint that does not exist, tokens never expire and carry a fixed scope set, and three endpoints ship with no authentication at all. This epic covers what exists and the work to make the API something an external team would build on with confidence.

DEV-01

Authenticate API requests with a Bearer token

Built
DeveloperMust
As a developer, I want to authenticate every request with a single Bearer header, so that both interactive sessions and machine clients use the same scheme.
Acceptance criteria
  1. Given a request with Authorization: Bearer <JWT>, when it is verified against JWT_PUBLIC_KEY and the configured issuer, then the request proceeds as that user with the login scope set.
  2. Given a request with Authorization: Bearer pat_…, when the secret's SHA-256 hash matches an accessTokens record, then the principal resolves to a service account named PAT <name> with the organisation and scopes from the record.
  3. Given no header, an unparseable header or an unknown token, when the request is handled, then it is rejected as unauthenticated.
  4. Given a JWT older than JWT_TOKEN_EXPIRATION_SECONDS (for example 604800), when it is presented, then it is rejected and the portal's refresh path issues a new one.
  5. Given a valid token for another organisation, when an org-scoped resource is requested, then it returns 403 rather than leaking existence.
Implementation notes

Implemented in the api package on @ubio/framework v17; the portal stores the JWT in localStorage ubio_auth_token and refreshes it every 5 minutes via POST /auth/refresh (ApiManager.startTokenRefreshInterval()).

Edge cases & risks

Google OAuth is the only login path, so an outage there blocks all interactive access; pat_ tokens never expire, so a leaked secret is valid until someone notices and deletes it.

DEV-02

Generate a personal access token and see the secret once

Built
DeveloperMust
As a developer, I want to mint a named token in the portal and copy its secret immediately, so that I can call the API from my own code.
Acceptance criteria
  1. Given I am on /tokens, when I open the generate dialog and supply a name, then POST /tokens returns {secret, accessToken} and the secret is displayed with a copy button.
  2. Given the secret is displayed, when the dialog renders, then the warning "Make sure to copy the token now…" is shown.
  3. Given I copy the secret, when the copy succeeds, then a toast confirms it and access_token_created is tracked.
  4. Given I close the dialog, when I reopen the token from the list, then the secret is not retrievable — only its SHA-256 hash is stored.
  5. Given I have no tokens, when the page renders, then "No access tokens found" is shown with a create CTA.
Implementation notes

views/AccessTokens.vueapi.createPersonalAccessToken(name)POST /tokens (PAT_MANAGE); secrets carry the pat_ prefix and are stored hashed on accessTokens.

Edge cases & risks

A user who dismisses the dialog before copying must create a second token, and the abandoned one lingers with full PAT scopes; the copy button depends on clipboard permissions that some managed browsers deny.

DEV-03

Manage the token list

Partial
Organisation adminShould
As an organisation admin, I want to see, rename and delete our tokens, so that credentials stay attributable and revocable.
Acceptance criteria
  1. Given tokens exist, when I open /tokens, then GET /tokens lists them with Name, Created At and Expiration Time.
  2. Given I choose Delete from the row context menu, when I confirm the dialog, then DELETE /tokens/{id} revokes it immediately and token_deleted is tracked.
  3. Given a deleted token is used, when the next request arrives, then it is rejected as unauthenticated.
  4. Gap: given the API exposes PUT /tokens/{id} for renaming, when I look for a rename control in the portal, then there is none — ApiManager implements only create, list and delete.
  5. Gap: given the table has an Expiration Time column, when tokens are listed, then it has nothing meaningful to show because PATs never expire.
Implementation notes

views/AccessTokens.vue with getPersonalAccessTokens() / createPersonalAccessToken() / deletePersonalAccessToken(); the unused rename endpoint is PUT /tokens/{id}.

Edge cases & risks

Tokens are org-scoped and every member has pat:manage on the JWT path, so any member can delete a colleague's integration credential without warning; a misnamed token cannot be corrected from the UI, encouraging duplicates.

DEV-04

Constrain what a personal access token can do

Partial
Organisation adminMust
As an organisation admin, I want machine tokens to be strictly less powerful than a logged-in session, so that an integration cannot restructure the organisation.
Acceptance criteria
  1. Given a pat_ token, when it calls an organisation-management endpoint, then it is refused — PAT scopes deliberately exclude orgs:manage.
  2. Given a pat_ token, when it attempts to mint another token, then it is refused — PAT scopes exclude pat:manage, so tokens cannot self-propagate.
  3. Given a pat_ token, when it calls search, hotels, reviews, metasearch, lists or exports, then it is permitted under hotels:view, reviews:view, metasearch:view, lists:manage and exports:manage.
  4. Gap: given I want a token that can only read, when I create one, then I cannot — the PAT scope set is fixed at creation with no picker.
  5. Gap: given the permission users:manage is declared, when any endpoint is called, then it is never enforced anywhere in the codebase.
Implementation notes

Scope constants live with the auth layer; JWT login scopes are orgs:manage, exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage, PATs get the same list minus orgs:manage and pat:manage. orgs:members:manage and orgs:members:view are commented out.

Edge cases & risks

Invitations are gated by LISTS_MANAGE, a known mis-scoping that lets any list-capable principal invite members; with no per-user roles, scope is the only control surface and it is currently all-or-nothing.

DEV-05

Rate-limit API consumers

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want per-principal rate limiting across the API, so that one client cannot degrade the platform for everyone.
Acceptance criteria
  1. Given a principal exceeds RATE_LIMIT_REQUESTS (default 100) within RATE_LIMIT_DURATION_SECONDS (default 60), when the next request arrives, then it returns 429 with Retry-After.
  2. Given REDIS_URL is configured, when limits are counted, then they are shared across API instances via Redis; otherwise an in-memory limiter is used.
  3. Given the limit resets, when the window rolls over, then the principal's requests succeed again without intervention.
  4. Gap: given any endpoint other than GET /metasearch — search, hotels, reviews, lists, exports — when it is called in a tight loop, then no rate limit applies at all.
  5. Given the in-memory fallback is active across replicas, when limits are enforced, then the effective limit is multiplied by the replica count.
Implementation notes

rate-limiter-flexible, keyed by principal, unit-tested as RateLimitService; wired only into the metasearch route because that endpoint proxies a paid upstream at METASEARCH_SERVICE_URL.

Edge cases & risks

Unlimited search and export traffic is the real exposure — exports stream large gzipped payloads to GCS; adding limits later will break existing integrations that unknowingly depend on burst behaviour.

DEV-06

Report the running API version

Built
DeveloperShould
As a developer, I want an unauthenticated status endpoint, so that I can confirm which version I am talking to when debugging.
Acceptance criteria
  1. Given I call GET /status with no credentials, when it responds, then it returns {version, service, timestamp}.
  2. Given the portal is open, when I open the profile menu, then it shows API: v{status.version} beside Portal: v{__PORTAL_VERSION__}.
  3. Given the API is unreachable, when the portal calls it, then the version line degrades rather than blocking the menu.
  4. Given the endpoint is public, when it responds, then it exposes no organisation, user or configuration detail beyond the version and service name.
Implementation notes

GET /status is deliberately unauthenticated; the portal calls it through ApiManager.getStatus() and renders it in ProfileMenu.vue. healthCheck() exists alongside it.

Edge cases & risks

Publishing the exact version tells an attacker which known issues apply — including the unauthenticated endpoints below; the landing footer's static "Status: all systems green" is not driven by this endpoint.

DEV-07

Publish an accurate OpenAPI specification

Partial
DeveloperMust
As a developer, I want a current OpenAPI document, so that I can generate a client instead of reverse-engineering the portal.
Acceptance criteria
  1. Given the docs package, when the generator runs, then an OpenAPI document is produced from the airtight schemas via OpenApiSpecBuilder and JsonSchemaBuilder.
  2. Gap: given the committed docs/openapi.json, when I inspect it, then it declares v0.27.2 against an API at v0.33.2 and lists 33 paths against 47 in the api package.
  3. Gap: given I try to generate an authenticated client, when I read the spec, then there are no securitySchemes, so Bearer auth is undocumented.
  4. Gap: given I generate method names, when I read the spec, then there are no operationIds, so generated clients get unusable names.
  5. Given a release is cut, when CI runs, then regeneration should be enforced and a stale spec should fail the build.
Implementation notes

The generator and its unit tests (OpenApiSpecBuilder, JsonSchemaBuilder) exist; the failure is that regeneration is not part of the tag-push pipeline that builds hotel-universe-api to eu.gcr.io/automation-cloud-registry.

Edge cases & risks

The live production system is ahead of the clone and already serves endpoints (/search/ai, /ai/assistant/, /direct-pricing/, /lists/:id/diff, /billing/plans) that appear in no specification at all; publishing a partial spec may be worse than none, since absent endpoints read as unsupported.

DEV-08

Correct the hand-written request documentation

Partial
DeveloperShould
As a developer, I want the example request collection to describe endpoints that actually exist, so that my first call succeeds.
Acceptance criteria
  1. Given docs/docs.http, when I follow it to create a machine credential, then it documents POST /auth/patan endpoint that does not exist; the real one is POST /tokens.
  2. Given the same file, when I copy the metasearch example, then it shows a locale default of gb while the API code defaults to us.
  3. Given the README, when I follow its layout and script names, then several are stale from the pre-monorepo structure.
  4. Given the documentation is corrected, when a route or default changes, then a check should fail if the examples no longer match the served routes.
Implementation notes

docs/docs.http and README.md in the hotel-universe-api repository; note the portal's metasearch filter defaults to locale gb while the API defaults to us, so the two are genuinely inconsistent, not just mis-documented.

Edge cases & risks

A documented-but-absent auth endpoint sends new integrators down a dead end before they ever reach /tokens; conflicting locale defaults change which market's prices come back, which is a correctness issue, not a cosmetic one.

DEV-09

Route developers from the marketing site into the docs

Built
ProspectShould
As a prospect, I want each product on the landing page to link to its reference material, so that I can judge fit before signing in.
Acceptance criteria
  1. Given the Hotel details product card, when I follow its CTA, then "Browse the schema" opens the hotel content reference.
  2. Given the Metasearch prices card, when I follow its CTA, then "View pricing API" opens the metasearch reference.
  3. Given the Direct pricing card, when I follow its CTA, then "Direct pricing guide" opens the direct ARI reference.
  4. Given I am signed in, when I use the navigation, then the external Docs item opens https://docs.hotel-universe.travel/guide/.
  5. Given a docs URL changes, when the constants are updated, then all CTAs follow — they must not be hard-coded per component.
Implementation notes

URLs are centralised in components/landing/links.ts (DOCS_URL, DOCS_METASEARCH_URL, DOCS_DIRECT_ARI_URL, SALES_MAILTO) and consumed by LandingProducts.vue; the in-app link comes from NavigationManager.getNavItems().

Edge cases & risks

The docs site lives outside both repositories, so nothing detects a broken deep link; "Talk to sales" is a mailto:hello@ubio.ai, which fails silently on machines with no mail client configured.

DEV-10

Close the unauthenticated endpoints

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want every endpoint to require a principal unless it is deliberately public, so that we are not giving away scraping and mutation for free.
Acceptance criteria
  1. Given GET /scrapers/google, GET /scrapers/booking and GET /scrapers/booking/rooms, when they are called with no credentials, then they currently execute — this is the defect and must return unauthenticated.
  2. Given POST /sources, when it is called with no credentials, then it currently updates source data — this must require a scoped internal principal.
  3. Given GET /img/resize?hash&w&h, when it is called with no credentials, then it currently resizes arbitrary hashes; it must at minimum be referrer- or signature-constrained and gain a cache-control header.
  4. Given the endpoints are secured, when the portal and scrapers call them, then existing internal flows continue to work.
  5. Given a request is refused, when it is logged, then the refusal is attributable for monitoring.
Implementation notes

Documented as known bugs in the API inventory; the same scraper classes are registered in the api app to power /scrapers/*, and the Google Travel path additionally calls a NodeScript endpoint with no auth. POST /invitations/{id}/accept and GET /status are intentionally public.

Edge cases & risks

The scraper endpoints spoof Booking and TripAdvisor clients with fixed device ids and proxy credentials — open access risks both cost and getting those identities blocked; /img/resize with cache-control commented out is an unbounded compute path on an open URL.

DEV-11

Give tokens an expiry and a rotation path

Proposed
Organisation adminMust
As an organisation admin, I want tokens to expire and to be rotatable without downtime, so that credentials do not live forever in someone's laptop.
Acceptance criteria
  1. Given I create a token, when I choose an expiry (30/90/365 days or never), then the choice is stored and shown in the Expiration Time column that already exists.
  2. Given a token is within 14 days of expiry, when I open /tokens, then it is flagged and a rotate action is offered.
  3. Given I rotate a token, when the new secret is issued, then both old and new are valid for a configurable overlap window before the old one stops working.
  4. Given a token has expired, when it is used, then the request is refused with an error distinguishing expiry from revocation.
  5. Given an organisation policy forbids non-expiring tokens, when a member tries to create one, then "never" is unavailable.
Implementation notes

accessTokens records already carry createdAt and createdBy; adding expiresAt mirrors the invitations collection, which already expires after 7 days, and the portal table already renders an Expiration Time column with nothing to put in it.

Edge cases & risks

Introducing expiry to a fleet of never-expiring tokens will break integrations at the cutover unless existing tokens are grandfathered; a rotation overlap window is a second live credential and must itself be audited.

DEV-12

Choose a token's permissions when creating it

Proposed
DeveloperMust
As a developer, I want to pick exactly which scopes a token carries, so that a read-only integration cannot write.
Acceptance criteria
  1. Given the generate dialog, when I create a token, then I can select from hotels:view, reviews:view, metasearch:view, lists:manage, exports:manage, with sensible presets including "read only".
  2. Given a token lacks a scope, when it calls the corresponding endpoint, then it is refused with an error naming the missing scope.
  3. Given the token list, when I view a row, then its scopes are displayed so I can audit them without recreating it.
  4. Given I attempt to grant orgs:manage or pat:manage to a PAT, when I submit, then it is refused — PATs must remain unable to manage the organisation or mint tokens.
  5. Given an existing token created before scopes were selectable, when it is used, then it retains the legacy fixed scope set until rotated.
Implementation notes

accessTokens.scopes[] already exists per record; only the creation path hard-codes the list. The picker belongs in the same dialog in views/AccessTokens.vue that shows the one-time secret.

Edge cases & risks

Fine-grained scopes make troubleshooting harder without the error taxonomy proposed for MCP; the invitation endpoints' LISTS_MANAGE gating must be fixed first or a "lists only" token will still be able to invite members.

DEV-13

Restrict a token to known source addresses

Proposed
Corporate travel buyerCould
As a corporate travel buyer, I want to bind a token to our egress IP ranges, so that a stolen secret is useless outside our network.
Acceptance criteria
  1. Given a token, when I add one or more CIDR ranges, then requests from outside them are refused.
  2. Given a refusal, when it is returned, then it is distinguishable from an authentication failure so a developer can diagnose it.
  3. Given no allowlist is configured, when the token is used, then behaviour is unchanged.
  4. Given I add an invalid CIDR, when I save, then the save is rejected with the expected format shown.
  5. Given a request arrives through a proxy, when the source address is evaluated, then the trusted forwarding header is used consistently.
Implementation notes

Stored on the accessTokens record and enforced in the same middleware that resolves the principal, before scope checks.

Edge cases & risks

Locally launched MCP servers run on developer laptops with dynamic addresses, so allowlisting conflicts directly with the npx distribution model; misconfigured proxy header trust either bypasses the control or blocks everyone.

DEV-14

Show per-token rate limits and usage

Proposed
Data analystShould
As a data analyst, I want to see call volume and limit consumption per token over time, so that I can find the integration that is burning our allowance.
Acceptance criteria
  1. Given tokens exist, when I open a usage view, then a chart shows calls per token per day for a selected range.
  2. Given a token has a custom rate limit, when I set it, then it overrides the organisation default for that token only.
  3. Given a token approaches its limit, when the threshold is crossed, then the organisation is warned before requests start failing.
  4. Given no calls were made in the range, when the view renders, then "No usage data found" is shown rather than an empty chart.
Implementation notes

Builds on GET /stats/ and views/Usage.vue (which already has org and date-range selectors and maps action codes to descriptions) plus the per-token attribution proposed in the MCP epic; charting would use the chart.js setup already present in HotelPriceInsights.vue.

Edge cases & risks

accessLogs counts distinct hotels per action rather than raw calls, so a request-count chart needs new instrumentation; per-token limits interact with the org-level limiter and need a clearly defined precedence.

DEV-15

Sign outbound webhooks

Proposed
Customer data engineerMust
As a customer data engineer, I want to receive signed webhooks when a list changes or an export is ready, so that I can stop polling and can verify authenticity.
Acceptance criteria
  1. Given I register an endpoint, when a subscribed event occurs — list diff available, export ready, direct-pricing batch complete — then it is delivered with a signature header and a timestamp.
  2. Given I hold the signing secret, when I verify a delivery, then a tampered payload fails verification.
  3. Given my endpoint returns a non-2xx, when delivery fails, then it is retried with exponential backoff and the attempts are visible in the portal.
  4. Given deliveries fail persistently, when the failure threshold is reached, then the endpoint is disabled and the organisation is notified.
  5. Given I rotate the signing secret, when both are active during the overlap, then deliveries verify against either.
Implementation notes

The landing flow already promises "push diffs to a webhook", but no outbound webhook feature exists — the only webhook in the codebase is inbound Stripe (POST /billing/webhook with stripe-signature), which is the natural model to copy.

Edge cases & risks

The Stripe handler swallows errors and always returns 200, a pattern that must not be repeated outbound; delivering export URLs by webhook leaks data to whatever endpoint is registered, so registration needs verification.

DEV-16

Publish official SDKs

Proposed
DeveloperShould
As a developer, I want maintained TypeScript and Python clients, so that I get types, retries and pagination without writing them.
Acceptance criteria
  1. Given the published packages, when I install them, then I can authenticate with a pat_ token and call search, hotels, reviews, metasearch, lists and exports with typed parameters and results.
  2. Given a paged endpoint, when I iterate results, then the SDK follows nextPageToken transparently.
  3. Given a 429, when the SDK receives it, then it honours Retry-After and retries with backoff by default.
  4. Given a 402, when the SDK receives it, then it raises a typed tier error carrying the upgrade message rather than a generic HTTP error.
  5. Given the API adds a field, when the SDK is regenerated from the spec, then the release is versioned semantically.
Implementation notes

Generation depends on DEV-07 — without securitySchemes and operationIds in docs/openapi.json, generated SDKs are unusable. The API is already a TypeScript ESM monorepo, so a first-party TS client can share the airtight types.

Edge cases & risks

Two SDKs plus the MCP server is three surfaces to keep in step with a spec that is already stale; default retries can silently multiply billable metasearch calls.

DEV-17

Ship an importable request collection

Proposed
ProspectCould
As a prospect, I want a Postman or Bruno collection I can import and run, so that I can evaluate the API in minutes.
Acceptance criteria
  1. Given the docs site, when I download the collection, then it covers auth, search, hotel detail, reviews, metasearch, lists and exports.
  2. Given I set a single pat_ token variable, when I run any request, then it authenticates without further edits.
  3. Given I run the collection end to end, when it completes, then it creates a list, exports it and downloads the result using chained variables.
  4. Given a request depends on a paid tier, when it is run on a trial organisation, then the collection's test assertion explains the 402 rather than reporting a generic failure.
Implementation notes

Would be generated from the same source as the OpenAPI document and replace the hand-maintained docs/docs.http, which is the current — and inaccurate — equivalent.

Edge cases & risks

A hand-edited collection drifts exactly as docs.http has; a collection that creates lists and exports against production consumes real quota during evaluation.

DEV-18

Publish an API changelog and versioning policy

Proposed
Customer data engineerMust
As a customer data engineer, I want a changelog and a stated deprecation policy, so that my integration does not break without warning.
Acceptance criteria
  1. Given the docs site, when I open the changelog, then every release lists added, changed and removed endpoints and fields with dates.
  2. Given a breaking change, when it is announced, then a minimum deprecation window is stated and the old behaviour keeps working until it closes.
  3. Given a deprecated endpoint, when it is called, then the response carries a deprecation header naming the replacement and the sunset date.
  4. Given a release ships with no changelog entry, when CI runs, then the release is blocked.
  5. Given I want notifications, when I subscribe, then changes are delivered by feed or email.
Implementation notes

Releases are already cut on tag push through GitHub Actions building hotel-universe-api and hotel-universe-scraping-service; the changelog should be generated as part of that pipeline and served beside https://docs.hotel-universe.travel/guide/.

Edge cases & risks

The production system is already ahead of the documented clone, so the first changelog has to reconcile a large undocumented delta; a stated deprecation window is a commitment that constrains how quickly known bugs such as the open /scrapers/* routes can be removed.

DEV-19

Provide a sandbox environment with fixture hotels

Proposed
DeveloperShould
As a developer, I want a test environment with stable fixture hotels, so that my CI can exercise the API without spending quota or hitting live prices.
Acceptance criteria
  1. Given a sandbox key, when I call the API, then I reach a sandbox host with a fixed catalogue of hotels whose huIds are stable across deploys.
  2. Given I call metasearch or direct pricing in the sandbox, when it responds, then deterministic fixture rates are returned and nothing is billed.
  3. Given I trigger an export in the sandbox, when it completes, then a real signed URL is produced so the download path is genuinely tested.
  4. Given a sandbox key is used against production, when the request arrives, then it is refused with an environment-mismatch error.
  5. Given error conditions, when I request them explicitly, then the sandbox can return 402, 429 and 410 on demand so clients can be tested against them.
Implementation notes

Reuses the existing integration-test fixtures (auth, users, organisation, invitation, tokens, lists, search, export, metasearch, sources) and the GCS export path (bucket hotel-universe-exports-dev, europe-west1) already used for development.

Edge cases & risks

Fixture hotels that diverge from real canonical records teach integrations to expect fields that may be absent live; a sandbox with no rate limits hides the pacing behaviour clients must handle in production.

DEV-20

Return a request id and a consistent error body

Proposed
Ubio support agentMust
As a Ubio support agent, I want every response to carry a request id and every error to use one shape, so that I can find a customer's failing call in the logs from the id alone.
Acceptance criteria
  1. Given any request, when it returns, then a request id header is present; if the client supplied one, then it is echoed.
  2. Given an error, when it returns, then the body carries a stable code, a human message, the request id and, for validation failures, the offending field.
  3. Given a customer quotes a request id, when I search internal logs, then the corresponding request, principal and organisation are found.
  4. Given a 500, when it is returned, then the body contains no stack trace or internal identifier beyond the request id.
  5. Given an error is surfaced in the portal, when a toast is shown, then the request id is included so the user can quote it.
Implementation notes

Would standardise messages that are currently free-text — "Upgrade to view. Contact sales at hello@ubio.ai", "Cannot remove the last member of an organization", "Export failed. Please try again later or contact support if the problem persists." — and pairs with the error taxonomy proposed in the MCP epic.

Edge cases & risks

Existing clients may parse today's message strings, so codes must be added alongside rather than replacing them; the portal already sanitises server HTML in the CSV upload error path, which shows error bodies are not currently uniform.

DEV-21

Accept idempotency keys on write endpoints

Proposed
DeveloperShould
As a developer, I want to safely retry a write after a timeout, so that I do not create duplicate lists, exports or invitations.
Acceptance criteria
  1. Given a write endpoint and an idempotency key header, when the same key is replayed within the retention window, then the original result is returned and no second resource is created.
  2. Given the same key is replayed with a different body, when the request arrives, then it is rejected as a conflict.
  3. Given no key is supplied, when the request is made, then behaviour is unchanged.
  4. Given the first request is still in flight, when the key is replayed, then the retry waits or returns a conflict rather than racing.
  5. Given the retention window has passed, when the key is replayed, then a new resource is created and the response says so.
Implementation notes

Applies to POST /lists/fromIds, POST /search/save, POST /lists/upload, POST /lists/:id/exports, POST /invitations, POST /tokens and POST /billing/checkout; lists are created fire-and-forget through PendingPromises, so the key record must be written before the async work begins.

Edge cases & risks

POST /tokens returns a secret exactly once, so replaying a key must return the same record without re-revealing the secret; duplicate exports are expensive because each streams a full gzipped payload to GCS.

DEV-22

Offer batch and asynchronous job endpoints

Proposed
Customer data engineerShould
As a customer data engineer, I want to submit thousands of hotel lookups as one job and collect the result, so that I do not have to make thousands of paged calls.
Acceptance criteria
  1. Given a batch of hotel ids, when I submit a bulk fetch, then a job id is returned immediately.
  2. Given a job id, when I poll it, then I receive a status of queued, running, complete or failed with progress.
  3. Given a completed job, when I fetch its result, then a signed download URL is returned with the same expiry semantics as exports.
  4. Given a batch exceeds the maximum size, when it is submitted, then it is rejected with the limit stated rather than silently truncated.
  5. Given a job partially fails, when it completes, then successful and failed items are separately enumerated with reasons.
Implementation notes

The building blocks exist: async list processing with status transitions created → processing → ready|error, the streaming export pipeline (Readable.from(dbStream) → transform → toJsonArray → gzip → GCS, constant memory), and the direct-pricing refresh batch pattern ({batchId} plus GET /direct-pricing/prices/batch/:batchId).

Edge cases & risks

Bulk hotel fetches count against the trial distinct-hotel quota in one shot, which can exhaust an evaluation instantly; without idempotency keys a retried batch submission doubles the work.

DEV-23

Let clients select the fields they need

Proposed
OTA product managerCould
As an OTA product manager, I want to request only the fields I render, so that payloads and transfer costs shrink.
Acceptance criteria
  1. Given a field-selection parameter naming the field groups I want (refs, info, location, rating, features, images, rooms), when I call a hotel endpoint, then only those groups are serialised.
  2. Given I omit the parameter, when the response returns, then the full record is returned as today.
  3. Given I name an unknown field group, when the request is made, then it is rejected naming the valid set.
  4. Given field selection is used on a paged search, when results return, then per-item size drops without changing pagination semantics.
  5. Given a selected group is unavailable for a hotel, when the response returns, then the corresponding meta availability flag explains the absence.
Implementation notes

canonicalData6 is already organised into exactly these groups via FIELD_GROUPS = [refs, info, location, rating, features, images, rooms] with per-group provenance in _dsNames and availability flags in HotelMeta, so projection maps cleanly onto the existing model. A GraphQL layer is the heavier alternative.

Edge cases & risks

Projections interact with the trial quota — a slim fetch still logs hotels:view and consumes a distinct hotel; caching becomes harder once each client requests a different shape.

DEV-24

Publish a status page and commit to uptime

Proposed
ProspectShould
As a prospect, I want a real status page with history and an SLA, so that I can justify building a customer-facing product on this API.
Acceptance criteria
  1. Given the status page, when I open it, then per-component health is shown for the API, metasearch, direct pricing, exports and the assistant, with a 90-day incident history.
  2. Given an incident, when it is opened, then updates are posted and delivered by feed to subscribers.
  3. Given a plan with an SLA, when uptime falls below the committed figure in a month, then the remedy is stated in the contract and visible in the portal.
  4. Gap: given the landing footer today, when it renders, then it shows a static "Status: all systems green" string that is not derived from any health signal — this must be replaced or removed.
  5. Given the status page itself is hosted separately, when the API is down, then the status page remains reachable.
Implementation notes

GET /status and healthCheck() provide the primitive signal; the footer string lives in components/landing/LandingFooter.vue. Component-level health needs probes for the metasearch proxy (METASEARCH_SERVICE_URL), GCS and MongoDB.

Edge cases & risks

A hard-coded "all systems green" during a live outage is actively misleading to prospects; committing to an SLA over a metasearch path documented at 2–5 s response times and a scraping estate dependent on third-party sites is a significant operational commitment.

Part 5

Lists, matching & the review queue

LIST 8 built3 partial12 proposed

Building a list

Everything in Hotel Universe starts from a list: the landing page's own flow section says "Everything depends on the starting point", and the headline promise is "Bring your list. See every rate." This epic covers every route into a list today — CSV upload in either of ListNew.vue's two modes, POST /lists/fromIds from a search selection, POST /search/save for a dynamic list, POST /lists/combine, and appending via POST /lists/:id/entries — plus the async created → processing → ready|error lifecycle that surrounds them. The proposed stories widen the front door: other file formats, pasted columns, mapping previews, recurring drops and per-row validation instead of one generic toast.

LIST-01

Upload a CSV without reshaping it first

Built
OTA supply managerMust
As an OTA supply manager, I want to upload my existing supply CSV exactly as it comes out of my system, so that I do not have to write a transformation script before I can see my hotels in Hotel Universe.
Acceptance criteria
  1. Given a CSV with twenty columns of which only one is a recognised hotel ID column, when I upload it via POST /lists/upload, then the extra columns are ignored and the list is created.
  2. Given the parser runs with csv-parse and columns: true, when a row carries at least one of huId/googleHotelId/googlePlaceId/bookingHotelId/tripAdvisorHotelId/expediaHotelId, then that row is kept as a listEntries document.
  3. Given a row carries none of those id fields, when the file is parsed, then that row is dropped rather than failing the whole upload.
  4. Given the upload succeeds, when I land on ListView, then the list appears with a status tag and its entry count.
Implementation notes

POST /lists/upload (multipart, field file), backed by the bulk CSV matching path described in the API report; the landing LandingFlow copy is the promise being kept: "Upload a CSV of the hotels you care about. No need to reshape it first — extra columns are ignored."

Edge cases & risks

Silently dropping id-less rows means a file with a mis-spelled header can produce an empty list with no explanation; the entry count in ListStats is the only signal the user gets.

LIST-02

Import by hotel reference ID

Built
Customer data engineerMust
As a customer data engineer, I want to tell the importer which ID type my file carries and which column holds it, so that every row resolves exactly rather than being guessed at.
Acceptance criteria
  1. Given I open ListNew.vue and choose the Match by hotel ID tab, when I pick a value in the hotelIdType select, then the upload posts that field alongside the file.
  2. Given my ID column is not the first column, when I type its header into columnName, then that column is read instead of the default first column.
  3. Given I leave columnName empty, when the file is parsed, then the first column is used.
  4. Given the upload succeeds, when the portal routes me on, then I land on ListView with ?tab=hotels&csvImport=ids.
  5. Given I name a columnName that does not exist in the file, when the file is parsed, then no rows resolve and the list reports zero hotels found.
Implementation notes

ListNew.vue posts form fields hotelIdType and columnName to POST {API_URL}/lists/upload through the PrimeVue FileUpload component with a manually attached Authorization header; matches the landing copy "Pick the type and every row resolves exactly."

Edge cases & risks

A wrong columnName fails silently as an empty result, which is indistinguishable from "none of your hotels are in our database"; telemetry records only list_csv_upload:id.

LIST-03

Import by hotel name and address

Built
Travel agency consultantMust
As a travel agency consultant with no OTA IDs in my programme file, I want to upload names and addresses instead, so that I can still build a list without buying an ID mapping first.
Acceptance criteria
  1. Given I choose the Match by hotel details tab in ListNew.vue, when I upload a file, then the required columns are name, at least one of streetAddress|address|city|postalCode|country, and lat/lon.
  2. Given my file has name and lat/lon but no address-like column, when I upload it, then the upload is rejected with a message naming the missing requirement.
  3. Given the file satisfies the requirements, when the upload succeeds, then I am routed to ListView with ?tab=review.
  4. Given a row matches with high confidence, when processing finishes, then it lands directly in Confirmed Hotels rather than in the review queue.
Implementation notes

ListNew.vue details mode plus the worked details-mode example on that screen; scored matching is the AddressMatcher path from packages/link. Landing copy: "No IDs? Upload names and addresses instead. Each row is matched by similarity and scored."

Edge cases & risks

Requiring lat/lon excludes the common case of an address-only file, since the matcher's locationMatch carries 0.3 of the confidence score; users with no coordinates have to geocode before they can start.

LIST-04

Reject a file that is not a CSV

Partial
Trial userShould
As a trial user, I want a clear message when my file is the wrong type, so that I know to re-export rather than assume the platform is broken.
Acceptance criteria
  1. Given I select an .xlsx file, when it is posted to POST /lists/upload, then the API responds 406 UploadListError with expected and received content types in the detail.
  2. Given that response, when the portal handles it, then a toast is shown with the server HTML sanitised out.
  3. Given the upload fails, when I return to ListNew.vue, then no empty list has been created.
  4. Given the file is text/csv but empty, when it is parsed, then the list is created with zero entries and status ready.
Implementation notes

The API enforces text/csv and raises UploadListError with expected/received detail; ListNew.vue sanitises the server HTML before toasting. Gap: the 406 detail is not surfaced field by field, so the user sees a single generic failure toast.

Edge cases & risks

Browsers and Excel exports frequently label CSVs as application/vnd.ms-excel or text/plain, so legitimate CSVs can be rejected on content type alone.

LIST-05

Pick from the full set of supported ID types

Partial
OTA product managerShould
As an OTA product manager, I want the ID type list to include the OTA identifiers we actually store, so that I can import a file keyed on Agoda or Trivago rather than remapping to Booking first.
Acceptance criteria
  1. Given the hotelIdType select in ListNew.vue, when I open it, then it offers huId plus GoogleHotels, GooglePlaces, Booking, TripAdvisor, Expedia and AllTopHotels.
  2. Given types/hotel.ts also defines 32 REF_EXTRA_OTA_NAMES such as agoda, trivago, hotels_com, kayak, priceline and traveloka, when I look for one of them, then it is available on the hotel profile's HotelReferences panel.
  3. Given I import against an extra OTA slug, when matching runs, then it resolves through the same ref lookup as a main source ID.
  4. Given an ID type is not supported for a given operation, when I select it for metasearch, then the option is restricted to METASEARCH_HOTEL_ID_TYPES (huId + GoogleHotels).
Implementation notes

ID types come from HOTEL_ID_TYPES in types/hotel.ts; the canonical HotelRefs in canonicalData6 only indexes the five main refs, while the extra OTA slugs live under refs.extra and are displayed by HotelReferences.vue. Gap: matchRecordsToHotelsByIds walks only the five main ref fields, so an extra-OTA import does not resolve server-side.

Edge cases & risks

Offering an ID type in the UI that the matcher does not walk produces a full list of unmatched entries with no reason the user can act on; the landing page explicitly advertises Trivago and Agoda IDs.

LIST-06

Create a list from selected search results

Built
Revenue managerMust
As a revenue manager, I want to tick hotels in the search results and save them as a list, so that I can go from browsing to a working comp set without leaving the screen.
Acceptance criteria
  1. Given I have selected hotels in HotelResultsTable.vue, when I choose "Save selection to static list" in the selection rail, then POST /lists/fromIds is called with hotelIds[].
  2. Given I do not supply a name, when the list is created, then it is named Selected Hotels YYYY-MM-DD.
  3. Given I supply a name in SaveSelectionForm.vue, when the list is created, then that name is used.
  4. Given my selection exceeds STATIC_SELECTION_LIMIT (1000) and virtual selection is active, when I save, then a virtual-selection toast explains what was actually saved.
  5. Given the request fails, when the toast is shown, then my selection is preserved in sessionStorage under hotel-universe:selected-hotels.
Implementation notes

api.saveListFromHotelIds(hotelIds, name?)POST /lists/fromIds; selection state is held by HotelSelectionManager with MAX_VIRTUAL_PREVIEW_HOTELS = 250 and createVirtualSelectionSaveToast in utils/selection.ts. Telemetry: list_saved:selection_drawer / list_saved:selection_table.

Edge cases & risks

Virtual selection saves a filter scope rather than an enumerated set, so a list saved from a large result set can differ from what the user believed they ticked if the index changes between selection and save.

LIST-07

Save a search as a dynamic list

Built
Corporate travel buyerMust
As a corporate travel buyer, I want to save my filter set rather than a frozen set of hotels, so that properties that later meet my policy appear without me redoing the search.
Acceptance criteria
  1. Given active filters on /search, when I click "Save search as dynamic list", then POST /search/save is called with the current filter set.
  2. Given no name is supplied, when the list is created, then it is auto-named from the filters, for example "name:hotel, country:ES, city:San".
  3. Given the list is created, when I open it, then it carries searchQuery and shows the Dynamic tag in Lists.vue.
  4. Given I have no filters at all, when I attempt to save, then the action is unavailable rather than creating an empty list.
Implementation notes

hotelManager.saveList()api.saveList(filters)POST /search/save (LISTS_MANAGE scope); there is no separate saved-search entity — a saved search is a list. Telemetry: list_saved:dynamic.

Edge cases & risks

The auto-derived name truncates filter values (city:San), so two different searches over San Sebastián and San Sebastián de los Reyes produce indistinguishable list names; a search with no clauses resolves server-side to $match {_id: 'impossible-id'} and yields nothing.

LIST-08

Combine lists with include and exclude

Built
OTA supply managerShould
As an OTA supply manager, I want to build a new list from several existing lists minus some others, so that I can produce "everything in our European supply that is not already contracted" in one step.
Acceptance criteria
  1. Given /lists/combine, when I select lists in the Include multiselect and click combine, then POST /lists/combine is called with includeLists.
  2. Given I also select lists in the Exclude multiselect, when the new list is built, then hotels present in the excluded lists are omitted.
  3. Given I select a list in Include and then the same list in Exclude, when the multiselects update, then the exclude selection is auto-pruned.
  4. Given I supply no name, when the list is created, then a default name is applied and the list appears in Lists.vue.
  5. Given every included hotel is also excluded, when the list is created, then it is created empty rather than erroring.
Implementation notes

ListCombine.vueapi.combineLists({name, includeLists, excludeLists})POST /lists/combine (live-only endpoint, not in the local API clone). Telemetry event list_combined.

Edge cases & risks

Combining dynamic lists snapshots them at combine time, so the result is static and silently diverges from its sources; there is no record on the new list of which lists it came from.

LIST-09

Append hotels to an existing list

Built
Data analystShould
As a data analyst, I want to add newly found hotels to a list I already have, so that I do not end up with five near-duplicate lists for the same project.
Acceptance criteria
  1. Given a selection on /search, when I choose "append to existing list" in SaveSelectionForm.vue, then existing lists are loaded via GET /lists and offered in a picker.
  2. Given I pick a list and confirm, when the request runs, then POST /lists/:id/entries is called with the selected huIds.
  3. Given some of the selected hotels are already in the list, when the append completes, then they are not duplicated.
  4. Given the list is dynamic, when I append to it, then the appended entries persist even though the list also refreshes from its searchQuery.
  5. Given the append fails, when the error is handled, then a toast is shown and the target list is left unchanged.
Implementation notes

api.appendListEntries(listId, huIds)POST /lists/:id/entries; the same endpoint backs "Add selected" / "Add all" in ListDiff.vue, which reports { added, skipped }.

Edge cases & risks

Appending static entries to a dynamic list produces a hybrid whose contents are not fully reproducible from its searchQuery, which then confuses the diff panel.

LIST-10

Watch a list build asynchronously

Built
Customer data engineerMust
As a customer data engineer uploading a large file, I want the screen to show me the list being built, so that I do not refresh or re-upload while matching is still running.
Acceptance criteria
  1. Given a list is created, when it is first written, then its status is created and it transitions to processing and then ready or error.
  2. Given status is created or processing, when I am on ListView, then ListPollingManager polls every 2.5 s and refreshes details, stats, entries and review status.
  3. Given status becomes ready, when the next poll returns, then polling stops and the final entry and hotel counts are shown.
  4. Given status becomes error, when the poll returns, then the list-level error text is displayed and a List Processing Failed toast is raised.
  5. Given I navigate away from ListView, when the component unmounts, then polling is stopped.
Implementation notes

lists.status is created→processing→ready|error with error|null; the portal side is ListPollingManager.startPolling(listId) / stopPolling() on a 2.5 s loop, driven from ListStats.vue. Server-side the work runs through PendingPromises (fire-and-forget with graceful drain).

Edge cases & risks

Fire-and-forget processing means a pod restart mid-build can leave a list stuck in processing forever, and the 2.5 s poll will run indefinitely against it; AccessTracking.listCreation gives up after 10 minutes.

LIST-11

Enforce a real upload size limit

Partial
Ubio platform engineerShould
As a Ubio platform engineer, I want the upload size limit enforced on the server rather than only in the browser, so that an API client cannot post a 500 MB file straight into the parser.
Acceptance criteria
  1. Given ListNew.vue, when I select a file larger than 1 MB, then the PrimeVue FileUpload blocks it client-side before any request is made.
  2. Given a direct POST /lists/upload call with a file larger than the configured limit, when the request is received, then it is rejected before the body is fully buffered.
  3. Given a rejection, when the response is returned, then it names the limit and the received size.
  4. Given a file within the limit, when it is uploaded, then behaviour is unchanged.
Implementation notes

The 1 MB cap lives only in the portal's FileUpload configuration (basic, auto, maxFileSize, accept text/csv); the API's POST /lists/upload has no equivalent limit, and HTTP_INCLUDE_UNPARSED_BODY means bodies can be buffered.

Edge cases & risks

1 MB is roughly 20–30k rows of IDs, well under what an OTA supply file looks like, so the client-side cap is simultaneously too tight for real users and no protection against a PAT-authenticated caller.

LIST-12

Ingest large files with resumable chunked upload

Proposed
OTA supply managerMust
As an OTA supply manager with 400,000 properties, I want to upload my whole supply file in chunks that resume after a dropped connection, so that the size of my catalogue is not a reason to fall back to the API.
Acceptance criteria
  1. Given a file above the inline threshold, when I start an upload, then the portal requests an upload session and posts the file in chunks with a resumable identifier.
  2. Given the connection drops at 60%, when I retry, then the upload resumes from the last acknowledged chunk rather than restarting.
  3. Given all chunks are received, when the session is finalised, then a single list is created and matching runs once over the assembled file.
  4. Given an upload session is abandoned, when the retention window elapses, then its partial data is discarded and no list is created.
  5. Given the file exceeds the plan's list size limit, when the session is opened, then it is refused up front with the limit stated.
Implementation notes

Would build on GoogleCloudStorageService, which already does uploadStream for exports but is configured non-resumable; the ingest path would switch to resumable uploads and hand the parser a GCS object rather than a request body.

Edge cases & risks

Very large lists interact badly with the existing 2.5 s ListPollingManager loop and with $lookup/$merge matching, so this needs a progress percentage rather than a binary status, and back-pressure on the matching stage.

LIST-13

Import from Excel

Proposed
Revenue managerShould
As a revenue manager, I want to upload the .xlsx my team actually maintains, so that I am not forced through a "save as CSV" step that mangles leading zeros in postcodes.
Acceptance criteria
  1. Given an .xlsx or .xlsm file, when I upload it, then the first sheet is parsed into rows and the existing matching pipeline runs unchanged.
  2. Given the workbook has multiple sheets, when I upload it, then I am asked which sheet to import.
  3. Given a cell is formatted as a number but holds a postcode with a leading zero, when it is read, then the original string value is preserved.
  4. Given the workbook is password-protected or corrupt, when it is parsed, then the upload fails with a message naming the reason rather than a content-type error.
Implementation notes

Extends the POST /lists/upload content-type check beyond text/csv and adds a sheet-to-rows adapter in front of the existing csv-parse row contract, so matchRecordsToHotelsByIds and the details matcher are untouched.

Edge cases & risks

Spreadsheets carry merged cells, header rows below the first row and trailing total rows, none of which the current "row is kept if it has one ID" rule handles; a header-detection heuristic is needed or every import silently drops rows.

LIST-14

Import from a Google Sheet

Proposed
Travel agency consultantCould
As a travel agency consultant whose programme lives in a shared Google Sheet, I want to point Hotel Universe at the sheet URL, so that I import from the source of truth instead of a stale download.
Acceptance criteria
  1. Given I paste a Google Sheets URL, when I submit it, then the platform reads the sheet and previews the detected columns.
  2. Given the sheet is not shared with the platform's service account, when I submit it, then I am shown the exact sharing instruction and the service account address.
  3. Given the import succeeds, when the list is created, then it records the source sheet ID and tab name.
  4. Given the sheet is later changed, when I open the list, then the recorded source lets me re-import with one click.
Implementation notes

Reuses the Google OAuth integration the platform already has for sign-in (POST /auth/google, GOOGLE_CLIENT_ID/SECRET) with an added Sheets read scope, or a service-account read; the row payload feeds the same upload pipeline.

Edge cases & risks

Asking for a Drive scope at sign-in time would degrade the login conversion the landing page depends on, so the scope must be requested incrementally and only for users who use this feature.

LIST-15

Paste a column of IDs

Proposed
DeveloperShould
As a developer testing an integration, I want to paste forty Booking IDs into a box, so that I can make a list in ten seconds without creating a file.
Acceptance criteria
  1. Given the paste box on ListNew.vue, when I paste values separated by newlines, commas or tabs, then they are split into rows and counted before I submit.
  2. Given I pick an ID type, when I submit, then the values are sent to the same matching path as a CSV import.
  3. Given a pasted value contains stray quotes or whitespace, when it is parsed, then it is trimmed before lookup.
  4. Given I paste more values than the plan's list size limit, when I submit, then the excess is refused with the count stated rather than truncated silently.
Implementation notes

The pattern already exists in search: filterDefs.ts defines a hotelIds filter with hotelIdType and hotelIdFilterMode: include|exclude taking paste-separated IDs, so the parsing helper can be lifted directly into ListNew.vue.

Edge cases & risks

Pasted lists arrive from spreadsheets with invisible non-breaking spaces and Excel's ="0123" wrappers; without normalisation these produce fully unmatched lists that look like a data-coverage problem.

LIST-16

Preview column mapping with a dry-run match estimate

Proposed
Customer data engineerMust
As a customer data engineer, I want to see how my columns were interpreted and roughly how many rows will match, before the list is created, so that I catch a wrong column choice in seconds rather than after a full import.
Acceptance criteria
  1. Given I select a file, when it is read, then the first rows are shown in a mapping table with each detected column mapped to a target field.
  2. Given the auto-detected mapping is wrong, when I change a column's target, then the preview re-renders against my mapping.
  3. Given I request a dry run, when it completes on a sample of rows, then I see an estimated match rate, an estimated needs-review count and an estimated unmatched count.
  4. Given the estimated match rate is under a low threshold, when the preview renders, then a warning suggests the most likely cause, such as the wrong hotelIdType.
  5. Given I cancel at the preview stage, when I leave the screen, then no list and no listEntries documents have been created.
Implementation notes

Needs a non-persisting variant of the matching path: matchRecordsToHotelsByIds currently uses $lookup + $merge, so a dry run would run the $lookup stage over a sample without the $merge. The results panel reuses the confidence and reason vocabulary from ListReviewQueue.vue.

Edge cases & risks

A sample-based estimate can mislead on files sorted by country or brand, so the sample must be drawn across the file rather than from the head; dry runs also add read load to canonicalData6 on top of live search.

LIST-17

Save an import template

Proposed
OTA supply managerCould
As an OTA supply manager who uploads the same export monthly, I want to save my column mapping and ID type as a named template, so that each month's import is one click rather than a re-setup.
Acceptance criteria
  1. Given a completed mapping, when I save it as a template with a name, then it is stored against my organisation.
  2. Given I upload a new file, when its header row matches a saved template, then that template is suggested automatically.
  3. Given I apply a template whose columns are missing from the new file, when it is applied, then the missing columns are flagged and the import is blocked until they are resolved.
  4. Given a colleague in the same organisation uploads a file, when they open the template picker, then organisation templates are available to them too.
Implementation notes

A small importTemplates collection scoped by orgId, mirroring how lists are scoped; there are no per-user roles in the platform, so organisation scope is the only meaningful sharing boundary today.

Edge cases & risks

Templates go stale when the upstream export changes shape, and silently applying a stale template is worse than no template — hence the explicit block rather than a best-effort partial apply.

LIST-18

Deduplicate rows within an upload

Proposed
Data analystShould
As a data analyst, I want duplicate rows in my file collapsed and reported, so that my list count matches my property count and my export is not inflated.
Acceptance criteria
  1. Given a file with the same Booking ID on three rows, when it is imported, then one entry is created and the duplicate count is reported.
  2. Given two rows carry different ID types that resolve to the same huId, when matching completes, then a single entry remains.
  3. Given duplicates were collapsed, when I open the list, then a line states how many rows were merged and lets me download the duplicate rows.
  4. Given every row in the file is a duplicate of one property, when the import finishes, then the list has one entry and the status is ready rather than error.
Implementation notes

Matching already converges different ref fields onto one huId through the fixed-order $lookup chain, so deduplication is mostly about reporting and about a uniqueness rule on listEntries per listId. The image pipeline carries a comparable "TODO deduplicate" in ImageUpdateService.

Edge cases & risks

Collapsing duplicates loses per-row provenance that some customers want for reconciliation, so the duplicate rows must remain downloadable rather than being discarded.

LIST-19

Start from a list template

Proposed
ProspectCould
As a prospect evaluating the platform, I want to create a starter list from a template such as "4★+ hotels in Spain" or a brand, so that I can see real output before I have a file of my own.
Acceptance criteria
  1. Given ListNew.vue, when I open the templates section, then I see templates by country, city and brand with an indicative hotel count.
  2. Given I pick a template, when I create the list, then a dynamic list is created from the equivalent saved search.
  3. Given I am on the trial tier, when the template list is created, then the trial hotel-view limit still applies to profiles I open from it.
  4. Given a template would return more hotels than my plan's list limit, when I select it, then the count and the limit are shown before I commit.
Implementation notes

Templates are pre-canned filter sets fed to POST /search/save, exactly as ListNew.vue already links to "example searches"; brand already exists as a filter in filterDefs.ts and country maps to the indexed location.countryCode token field.

Edge cases & risks

Template lists made by many trial users at once create a large number of dynamic lists whose diffs all need refreshing; template counts must come from $searchMeta and will be approximate above the 1000 threshold.

LIST-20

Import from an S3 or SFTP drop

Proposed
Customer data engineerCould
As a customer data engineer, I want to drop a file into a bucket or SFTP folder and have a list built from it, so that my nightly job does not have to hold a multipart upload open.
Acceptance criteria
  1. Given I configure a source with credentials and a path prefix, when a new file appears at that prefix, then an import runs against it.
  2. Given the import completes, when I look at the list, then it records the source object key and the time it was picked up.
  3. Given the credentials are rejected, when the poll runs, then the source is marked failed with the provider error and the organisation is notified.
  4. Given the same file is seen twice, when the second poll runs, then it is not re-imported.
Implementation notes

The credentials and polling model mirror the existing GoogleCloudStorageService configuration surface (GCS_PROJECT_ID, GCS_BUCKET_NAME, GCS_SERVICE_ACCOUNT_KEY), and the scheduler can reuse PeriodicTaskRunner.schedule(task, interval, name) from the scrapers package.

Edge cases & risks

Storing customer cloud credentials raises a security surface the platform does not have today; note that POST /sources currently has no auth check at all, so this area needs hardening before adding more of it.

LIST-21

Schedule a recurring import

Proposed
OTA supply managerShould
As an OTA supply manager, I want my supply file re-imported on a schedule into the same list, so that the list tracks my contracted estate without a manual upload each week.
Acceptance criteria
  1. Given a configured source, when I set a schedule, then the import runs on that cadence and updates the existing list rather than creating a new one.
  2. Given a scheduled run adds and removes properties, when it completes, then the additions and removals are recorded against the list.
  3. Given a scheduled run fails, when the next run is due, then it still executes and the failure is visible on the list.
  4. Given I pause the schedule, when the cadence elapses, then no import runs and the list is untouched.
Implementation notes

Builds on LIST-20 plus PeriodicTaskRunner; the change record it produces is the same data LMGT-18 needs, and the "list gained hotels" notification would reuse the alerting from LMGT-22.

Edge cases & risks

A recurring import that removes rows can silently delete entries a human approved in the review queue; removals should default to a proposal in the review queue rather than an immediate delete.

LIST-22

Enforce list size limits by plan

Proposed
Ubio finance opsShould
As Ubio finance ops, I want list size bounded by the customer's plan, so that an unbounded import does not consume rich-content or export volume we have not sold.
Acceptance criteria
  1. Given a plan with a list size limit, when an import would exceed it, then the import is refused before any entries are written and the limit and attempted count are shown.
  2. Given I am within 90% of the limit, when the list is created, then a quota indicator is shown on ListStats.
  3. Given I upgrade my plan, when the new limit applies, then a previously refused import succeeds on retry.
  4. Given I am on the trial tier, when I import, then the limit is the trial limit and the message points at billing rather than at support.
Implementation notes

Extends AccessTierService, which already implements ACCESS_TIER_TRIAL_LIMIT (100 distinct hotels viewed) and allowsMetasearch/allowsReviews; the portal side reuses UpgradeRequired.vue and the rich-content quota helpers in utils/billingFormatters.ts.

Edge cases & risks

The existing trial limit counts distinct hotels viewed all-time from accessLogs, which is a different unit from list size; mixing the two in one message would confuse users about which limit they hit.

LIST-23

Report validation errors per row

Proposed
Customer data engineerMust
As a customer data engineer, I want a downloadable list of exactly which rows failed and why, so that I can fix my file instead of guessing from a single toast.
Acceptance criteria
  1. Given an import with malformed rows, when it completes, then each failed row is recorded with its row number and a machine-readable reason.
  2. Given failures exist, when I open the list, then I can download a CSV of the original rows with an appended error column.
  3. Given a row fails because it has no recognised ID column, when the reason is written, then it is distinguishable from a row that had an ID which matched nothing.
  4. Given every row fails, when the import finishes, then the list status is error with a summarised reason rather than ready with zero entries.
  5. Given no rows fail, when the import finishes, then no error download is offered.
Implementation notes

The reason vocabulary should extend the review queue's existing codes (missing_matching_data | id_lookup_not_found | no_candidates_above_threshold | all_candidates_rejected) rather than inventing a parallel set; the download can reuse the CSV pattern already implemented for downloadListUnlinkedCsv on the internal IBE screens.

Edge cases & risks

Retaining original rows to make the error CSV means storing customer data beyond listEntries, which needs a retention rule; today failing rows are simply dropped and nothing is retained.

MTCH 8 built3 partial11 proposed

Entity matching & the review queue

Matching is the step the landing page sells hardest: "which of these are the same property" is the OTA's typical ask, and the flow promises that "confident rows land straight in the list, uncertain ones go to a review queue for a human call, and unmatched rows stay visible for follow-up". Two engines sit behind that promise — a deterministic ID resolver that walks HotelRefs in a fixed priority order, and a scored similarity matcher built on AddressMatcher with a 0.7 acceptance threshold. This epic covers both, the ListReviewQueue.vue surface that exposes their output to a human, and the proposals that would make the scoring adjustable, auditable and able to learn.

MTCH-01

Resolve rows by reference ID in priority order

Built
Customer data engineerMust
As a customer data engineer, I want rows carrying an OTA ID to resolve deterministically, so that identity is not left to a similarity score when I already know the answer.
Acceptance criteria
  1. Given imported rows, when matchRecordsToHotelsByIds runs, then it performs $lookup + $merge per ID field in the fixed order googleHotelId → googlePlaceId → bookingHotelId → tripAdvisorHotelId → expediaHotelId.
  2. Given a row carries both a googleHotelId and a bookingHotelId, when matching runs, then the first match in that order wins and later fields are not applied.
  3. Given a row's IDs match nothing, when matching completes, then the entry retains huId: null.
  4. Given a row's hotelIdType is huId, when matching runs, then it short-circuits to a direct $match {_id} rather than a lookup chain.
  5. Given matching finishes, when I call GET /lists/:id/stats, then total and hotels differ by exactly the number of unresolved entries.
Implementation notes

Implemented in the bulk CSV matching path over canonicalData6, which indexes each refs.* field; HotelRefs holds googleHotelId, googlePlaceId, bookingHotelId, tripAdvisorHotelId, expediaHotelId and DS_NAMES gives the same priority order used by the canonical merge.

Edge cases & risks

"First match wins" means a stale googleHotelId beats a correct bookingHotelId on the same row with no signal that the two disagreed; conflicting IDs are a real data-quality trap in customer supply files.

MTCH-02

Match rows by name, address and coordinates

Built
Travel agency consultantMust
As a travel agency consultant with no IDs, I want each row scored against candidate properties, so that I still get a usable list from names and addresses alone.
Acceptance criteria
  1. Given a details-mode import, when a row is processed, then a candidate is retrieved and scored against the uploaded name, address, postcode and coordinates.
  2. Given the score is at or above 0.7, when matching completes, then the row is confirmed and appears in Confirmed Hotels.
  3. Given the score is below the acceptance threshold but candidates exist, when matching completes, then the row is placed in the review queue as needs_review.
  4. Given no candidate scores above the threshold, when matching completes, then the row is unmatched with reason no_candidates_above_threshold.
  5. Given a row is missing the fields needed to score, when it is processed, then it is unmatched with reason missing_matching_data.
Implementation notes

AddressMatcher computes nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accepting at ≥ 0.7; similarity is Jaccard plus Levenshtein, postcode is exact after normalisation and location is Haversine. Candidate retrieval in packages/link uses Google Places Text Search with locationBias.circle radius 1000 and includedType: lodging.

Edge cases & risks

With 0.6 of the score coming from postcode and coordinates, a row with a good name but a slightly wrong postcode scores below threshold, while two neighbouring properties in one postcode can both clear it.

MTCH-03

Review uncertain rows side by side

Built
OTA supply managerMust
As an OTA supply manager, I want to see my uploaded row next to the candidate hotel, so that I can make the call the matcher would not make.
Acceptance criteria
  1. Given a list with rows needing review, when I open the Needs review tab, then ListReviewQueue.vue renders in mode="needs_review" with Uploaded row data, Candidate hotel, Match confidence and State columns.
  2. Given a row, when I expand it, then the panel compares name, street, city, postal code, country and coordinates for both sides, plus the candidate's HU ID and data-availability icons.
  3. Given a row's state, when it is rendered, then it is tagged confirmed, needs_review or unmatched with a hint.
  4. Given the list has no rows to review, when the tab renders, then it shows "No items to review."
Implementation notes

ListReviewQueue.vue (1094 lines) driven by ListReviewManager (entries, total, hasMore, pageSize = 10, load, loadMore, refreshStatus); the tab badge count comes from ListStatsManager.needsReview.

Edge cases & risks

The queue paginates at 10 with load-more, so an import with thousands of uncertain rows is not humanly reviewable without the bulk tools proposed later in this epic.

MTCH-04

Read per-signal confidence

Built
Data analystShould
As a data analyst, I want to see which signal made the match uncertain, so that I can tell a genuine mismatch from a formatting problem in my own file.
Acceptance criteria
  1. Given an expanded review row, when the confidence pills render, then there is a pill for overall, name, address, postalCode and geo.
  2. Given the geo pill is low while name and address are high, when I read the row, then the discrepancy is visible without opening the hotel profile.
  3. Given a signal could not be computed because the uploaded row lacked that field, when the pill renders, then it indicates absence rather than a zero score.
  4. Given the overall pill, when it is compared to the per-signal pills, then it reflects the weighted combination rather than an average.
Implementation notes

Pills come from the per-signal confidence block in ListReviewQueue.vue's expansion panel; the underlying signals correspond one-to-one with the AddressMatcher components (name 0.2, address 0.2, postcode 0.3, location 0.3).

Edge cases & risks

Displaying an unweighted per-signal pill next to a weighted overall score reads as inconsistent to users — a row can show three green pills and an amber overall because postcode alone carries 0.3.

MTCH-05

Approve or reject a row

Built
OTA supply managerMust
As an OTA supply manager, I want to accept or reject a proposed match, so that my list contains only properties I have signed off.
Acceptance criteria
  1. Given a review row, when I click Approve, then POST /lists/:id/entries/approve is called and the row moves to Confirmed Hotels.
  2. Given a review row, when I click Reject, then POST /lists/:id/entries/reject is called and the row moves to the Rejected / Unmatched tab.
  3. Given the row is rejected, when the reason is recorded, then it is all_candidates_rejected.
  4. Given the approve request fails, when the error is handled, then the row stays in the queue and its state is not changed optimistically.
  5. Given a row is approved, when GET /lists/:id/stats is refreshed, then hotels increases and needsReview decreases.
Implementation notes

api.approveListEntries / api.rejectListEntriesPOST /lists/:id/entries/approve|reject, wired through ListReviewManager.approve(listId, matches) / reject(listId, matches).

Edge cases & risks

There is no undo — a rejected row can only be recovered by re-importing it, and rejection today is per-list rather than a durable statement about the two entities.

MTCH-06

Approve rows in bulk

Built
Data analystShould
As a data analyst with a few hundred uncertain rows, I want to approve many at once, so that review is proportionate to the size of the import.
Acceptance criteria
  1. Given the review queue, when I tick several rows and click Approve selected, then all selected rows are approved in one request.
  2. Given the review queue, when I click Approve all, then every row currently in needs_review for that list is approved.
  3. Given Approve all runs on a list with more rows than are loaded on screen, when it completes, then rows beyond the loaded page are included.
  4. Given a bulk approve partially fails, when the response is handled, then the successful rows are reflected and a toast reports the failures.
Implementation notes

Approve selected / Approve all are rendered by ListReviewQueue.vue and call the same POST /lists/:id/entries/approve endpoint with a batch of matches; ListPollingManager refreshes review status afterwards.

Edge cases & risks

"Approve all" over a queue the user has only seen 10 rows of is effectively a blind accept of every sub-threshold match, which is exactly the failure mode the review queue exists to prevent.

MTCH-07

See unmatched rows with a reason

Built
Customer data engineerMust
As a customer data engineer, I want unmatched rows to stay visible with the reason they failed, so that I can follow up rather than lose them.
Acceptance criteria
  1. Given a list with unmatched rows, when I open the Rejected / Unmatched tab, then ListReviewQueue.vue renders in mode="unmatched" with Uploaded row, Location and Reason columns.
  2. Given a row that had an ID which resolved to nothing, when its reason renders, then it reads id_lookup_not_found.
  3. Given a row that lacked the fields needed to match, when its reason renders, then it reads missing_matching_data.
  4. Given there are no rejected or unmatched rows, when the tab renders, then it shows "No rejected or unmatched rows."
Implementation notes

Reason codes missing_matching_data | id_lookup_not_found | no_candidates_above_threshold | all_candidates_rejected are defined in ListReviewQueue.vue; the landing flow copy promises "unmatched rows stay visible for follow-up."

Edge cases & risks

The reasons are shown as raw-ish codes rather than plain-English guidance, and none of them tells the user what to do next — id_lookup_not_found could mean a wrong ID type or genuinely absent coverage.

MTCH-08

Compare items in list against hotels found

Built
OTA product managerShould
As an OTA product manager, I want a headline number for how much of my file resolved, so that I can judge coverage before committing to a subscription.
Acceptance criteria
  1. Given a processed list, when GET /lists/:id/stats is called, then it returns {total, hotels} where total is entries and hotels is resolved properties.
  2. Given the ListStats card, when it renders, then it shows status, created date, items in list and hotels found.
  3. Given the list is still processing, when the card renders, then the counts update every 2.5 s until the status is terminal.
  4. Given hotels is zero on a completed list, when the card renders, then the discrepancy is visible rather than presented as a successful import.
Implementation notes

ListStatsManager exposes total, hotelsTotal, needsReview, unmatched, subscribedHotels, notSubscribedHotels; ListStats.vue starts ListPollingManager while processing.

Edge cases & risks

total counts kept rows, not uploaded rows — rows dropped at parse time for having no ID never appear in either number, so a file can report 100% match while a third of it was discarded.

MTCH-09

Match against a candidate set rather than one candidate

Partial
OTA supply managerShould
As an OTA supply manager, I want the reviewer to see that several properties were considered, so that I can tell "we found nothing" apart from "we found four and picked badly".
Acceptance criteria
  1. Given multiple candidates were scored for a row, when the row is reviewed, then the best candidate is displayed with its confidence.
  2. Given all candidates fell below the threshold, when the row is classified, then the reason is no_candidates_above_threshold.
  3. Given the reviewer disagrees with the displayed candidate, when they reject it, then the reason recorded is all_candidates_rejected.
  4. Given several candidates were scored, when the reviewer expands the row, then only the top candidate is shown today.
Implementation notes

packages/link scores multiple Google Places candidates with AddressMatcher and picks the best, and the reason codes in ListReviewQueue.vue clearly imply a candidate set. Gap: the review UI surfaces exactly one candidate hotel column, so the rest of the candidate set is computed and then discarded.

Edge cases & risks

Discarding the runner-up means a reviewer who rejects the top candidate has no path forward other than manual search, which is why rejection is currently terminal.

MTCH-10

Fixed weights and threshold

Partial
Ubio data ops engineerShould
As a Ubio data ops engineer, I want the matching weights and threshold to be configuration rather than constants, so that I can tune them without a release.
Acceptance criteria
  1. Given the current implementation, when a row is scored, then weights of 0.2/0.2/0.3/0.3 and an acceptance threshold of 0.7 are applied.
  2. Given the threshold should change, when it is changed today, then it requires a code change and a deploy.
  3. Given weights were configurable, when they are changed, then existing lists are not retrospectively re-scored.
  4. Given an invalid weight set is configured, when it is loaded, then it is rejected rather than silently normalised.
Implementation notes

The constants live in AddressMatcher in packages/link; the platform already uses mesh-config for environment-backed configuration, so promoting them to config vars alongside GOOGLE_PLACES_API_KEY is the mechanical part.

Edge cases & risks

Changing the threshold changes what "confirmed" means without changing any existing data, so match confidence recorded before and after a change is not comparable — this is the argument for MTCH-19's audit trail.

MTCH-11

Distinguish neighbouring street numbers

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want "123 Main St" and "124 Main St" to score differently, so that two distinct properties on the same street are not merged.
Acceptance criteria
  1. Given two addresses differing only in house number, when addressSimilarity is computed today, then the Jaccard and Levenshtein blend scores them as near-identical.
  2. Given identical postcodes and coordinates within metres, when the total score is computed, then the pair clears the 0.7 threshold.
  3. Given a house-number comparison is added, when the numbers differ, then the address component is penalised regardless of overall string similarity.
  4. Given a source address genuinely has no house number, when the comparison runs, then it must not penalise the score.
Implementation notes

The limitation is documented in the link package: "123 Main St" vs "124 Main St" not distinguished well. AddressSplittingService already posts {rawAddress} to a NodeScript endpoint to normalise into a HotelLocation, so a parsed house-number field is available to compare on.

Edge cases & risks

This is the highest-severity matching defect, because a false positive merges two properties in canonicalData6 and then propagates into pricing and exports; it is far worse than a false negative sitting in the review queue.

MTCH-12

Set the confidence threshold per organisation

Proposed
Organisation adminShould
As an organisation admin, I want to choose how confident a match must be before it is auto-confirmed, so that I can trade review effort against risk according to how my team uses the data.
Acceptance criteria
  1. Given an organisation setting for auto-confirm threshold, when an import runs, then rows are classified against that threshold rather than the global 0.7.
  2. Given I raise the threshold, when the next import runs, then more rows land in needs_review and none are auto-confirmed below the new value.
  3. Given I set a threshold outside the permitted range, when I save, then it is rejected with the permitted range stated.
  4. Given the threshold is changed, when I open a list imported earlier, then its recorded classifications are unchanged and the threshold in force at import time is displayed.
Implementation notes

Stored on organizations alongside tier, read by the matching path; the portal surface belongs on Settings.vue, which currently only renames the organisation. Depends on MTCH-10 promoting the constant out of code.

Edge cases & risks

There are no per-user roles — every org member has identical scopes — so any member could change a setting that alters data quality for the whole organisation; this needs at minimum an audit entry.

MTCH-13

Show the top N candidates for a row

Proposed
OTA supply managerMust
As an OTA supply manager, I want to pick from the top few candidates rather than only accept or reject one, so that a near-miss on the top candidate does not cost me the row.
Acceptance criteria
  1. Given a row with multiple scored candidates, when I expand it, then up to N candidates are listed in descending confidence with their per-signal pills.
  2. Given I select a candidate that is not the top one, when I approve, then the entry resolves to that candidate's huId.
  3. Given I reject all listed candidates, when the row is classified, then the reason is all_candidates_rejected.
  4. Given only one candidate was retrieved, when I expand the row, then the panel renders as it does today without an empty list.
Implementation notes

Requires persisting the scored candidate set rather than only the winner, and extending the approve payload in POST /lists/:id/entries/approve to carry a chosen huId; the comparison panel in ListReviewQueue.vue already renders every field for one candidate and would become a repeated block.

Edge cases & risks

Persisting candidate sets multiplies review-queue storage per import; a cap on N and a retention window are needed, and the panel becomes unwieldy on mobile at N above three.

MTCH-14

Approve in bulk by confidence band

Proposed
Data analystShould
As a data analyst, I want to approve everything above a confidence I choose and review only the rest, so that a 5,000-row queue becomes a 200-row queue.
Acceptance criteria
  1. Given a review queue, when I open the bulk panel, then rows are bucketed into confidence bands with a count per band.
  2. Given I approve a band, when the request completes, then every row in that band for that list is confirmed, including rows not yet loaded on screen.
  3. Given I approve a band, when the action is recorded, then the band and the count are captured for audit.
  4. Given a band contains rows whose geo signal is missing, when I approve the band, then those rows are excluded and reported separately.
Implementation notes

Extends the existing Approve all control in ListReviewQueue.vue with a band filter; the counts can be derived from the same aggregation that feeds ListStatsManager.needsReview.

Edge cases & risks

Band-level approval encourages a single click over thousands of rows, so the audit record and a per-band sample preview are essential, otherwise this reproduces the blind-accept problem of Approve all at greater scale.

MTCH-15

Learn from past approve and reject decisions

Proposed
Ubio data ops engineerCould
As a Ubio data ops engineer, I want the matcher to take account of decisions reviewers have already made, so that the same pair is not re-presented for review on every import.
Acceptance criteria
  1. Given a reviewer approved a specific uploaded row signature against a huId, when an equivalent row arrives in a later import, then it is auto-confirmed and marked as decided-by-precedent.
  2. Given a reviewer rejected a pair, when the same pair would be proposed again, then it is suppressed and the next candidate is considered.
  3. Given a precedent-based confirmation, when it is displayed, then the source decision and its date are shown.
  4. Given precedents conflict, when the newer decision was made, then it takes priority and the conflict is logged.
Implementation notes

Requires a decisions store keyed on a normalised row signature plus huId, populated from POST /lists/:id/entries/approve|reject; scoped by orgId at minimum, since one customer's identity rules should not silently affect another's.

Edge cases & risks

Precedents entrench mistakes — an incorrect early approval becomes invisible policy, so precedent-based confirmations must be reviewable and reversible, and cross-organisation reuse should not be the default.

MTCH-16

Use phone, website and brand as matching signals

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want phone number, website domain and brand to contribute to the score, so that matching is not so dependent on postcode and coordinate quality.
Acceptance criteria
  1. Given the uploaded row and the candidate both carry a phone number, when scoring runs, then normalised phone equality contributes to the score.
  2. Given both carry a website, when scoring runs, then the registrable domain is compared rather than the full URL.
  3. Given a strong signal such as an exact website domain match, when a weaker signal disagrees, then the overall confidence reflects the strong signal.
  4. Given neither side carries the additional field, when scoring runs, then the score is renormalised over the available signals rather than penalised.
Implementation notes

HotelInfo already stores website and phoneNumber, and brand is already a search filter in filterDefs.ts; the change is in AddressMatcher's weighting model, which currently sums exactly four fixed terms.

Edge cases & risks

Chain properties share a central phone number and a single brand domain, so an unqualified website or phone match would merge every property in a chain — these signals must be conditioned on the chain-level handling in MTCH-17.

MTCH-17

Match at chain and brand level

Proposed
OTA product managerCould
As an OTA product manager, I want chain-aware matching, so that "Premier Inn London Bank" and "Premier Inn London Bridge" are not treated as the same property because their names and brand agree.
Acceptance criteria
  1. Given both sides share a recognised brand, when scoring runs, then the shared brand token is discounted from the name similarity.
  2. Given the brand is shared, when disambiguating, then the location signals are weighted more heavily.
  3. Given a row names a chain but no specific property, when it is processed, then it is unmatched with an ambiguity reason rather than assigned to an arbitrary property.
  4. Given a brand is unknown to the platform, when scoring runs, then behaviour falls back to the current name comparison.
Implementation notes

brand and sub-brand are already displayed by HotelInfo.vue and filterable via filterDefs.ts; a brand token list would be derived from those values and applied as a stop-list in the name-similarity step of AddressMatcher.

Edge cases & risks

Brand data is unevenly populated across DS_NAMES sources, so brand-conditioned scoring will behave differently for a Booking-sourced property than a Google-sourced one, which makes confidence harder to interpret.

MTCH-18

Re-run matching after new data lands

Proposed
OTA supply managerShould
As an OTA supply manager, I want rows that failed to match to be retried when coverage improves, so that a property we scraped last week does not stay unmatched forever.
Acceptance criteria
  1. Given a list with unmatched entries, when I request a re-match, then only unmatched and rejected-with-no-candidate entries are re-processed.
  2. Given a re-match resolves an entry, when it completes, then the entry moves into the review queue or Confirmed Hotels according to its new score.
  3. Given a re-match finds nothing new, when it completes, then the entry is unchanged and the attempt timestamp is recorded.
  4. Given entries that a human explicitly rejected, when a re-match runs, then they are not silently re-proposed unless I opt in.
  5. Given a re-match is already running for a list, when I request another, then the second request is refused rather than queued twice.
Implementation notes

The ingest side already knows when new data lands — HotelDataUpdateService recomputes canonical records and the scrapers run on a SCRAPING_INTERVAL with a 30-day staleTimeThresholdMs — so a re-match can be triggered per list or on a schedule using PeriodicTaskRunner.

Edge cases & risks

Re-matching a large list re-runs the Places lookup path and carries a per-call cost against GOOGLE_PLACES_API_KEY; it needs rate limiting, since today only GET /metasearch is rate-limited at all.

MTCH-19

Export a match audit

Proposed
Corporate travel buyerShould
As a corporate travel buyer with a compliance obligation, I want to export exactly how each row of my file was resolved, so that I can evidence which property each programme entry refers to.
Acceptance criteria
  1. Given a completed list, when I request a match audit export, then I receive one row per uploaded row with the original values, the resolved huId, the state, the reason and the confidence.
  2. Given a row was decided by a human, when the audit is generated, then the decision, the deciding user and the timestamp are included.
  3. Given the export is requested, when it completes, then it appears in the exports drawer alongside full, preview and reviews.
  4. Given the download link has expired, when I click download, then a 410 is returned and I am offered a re-generate action.
Implementation notes

Slots into the existing ExportService pipeline (Readable.from(dbStream) → transform → toJsonArray → gzip → GCS) as a fourth type alongside full|preview|reviews, with the same 30-day expiresAt and 15-minute signed URL; the live portal already offers CSV as well as JSON.

Edge cases & risks

The audit contains the customer's original uploaded rows, so its retention and access rules should be tighter than a hotel-content export; the current export types are gated only by the EXPORTS_MANAGE scope, which every member holds.

MTCH-20

Match without creating a list

Proposed
AI agent builderMust
As an AI agent builder, I want to post rows and get matches back synchronously, so that my agent can resolve identity mid-conversation without leaving list objects behind.
Acceptance criteria
  1. Given a batch of rows below a documented size, when I POST them to the matching endpoint with a PAT, then I receive per-row huId, state, reason and confidence in the response.
  2. Given the batch exceeds the synchronous limit, when I post it, then I receive a job handle and can poll for the result.
  3. Given the call succeeds, when I inspect my organisation's lists, then no list or listEntries documents have been created.
  4. Given my PAT lacks the required scope, when I call the endpoint, then I receive 403 without any matching being performed.
  5. Given I exceed my rate limit, when I call again, then I receive 429 with Retry-After.
Implementation notes

The natural shape is a non-persisting variant of matchRecordsToHotelsByIds (the $lookup stage without $merge), exposed under the existing lists:manage or a new match scope; it also gives the MCP server a matching primitive to sit alongside hotels.search and lists.create.

Edge cases & risks

This is the first genuinely expensive synchronous endpoint besides /metasearch; rate limiting is currently applied to GET /metasearch only, so the limiter would need extending or this endpoint will become the cheapest way to hammer Atlas Search.

MTCH-21

Report match quality per import

Proposed
OTA product managerShould
As an OTA product manager, I want a quality summary for each import, so that I can see whether a poor result is our data or my file.
Acceptance criteria
  1. Given a completed import, when I open its quality report, then I see rows uploaded, rows kept, auto-confirmed, needs-review, unmatched and the breakdown by reason.
  2. Given the import used ID matching, when the report renders, then it shows which ID field resolved each match across the fixed priority order.
  3. Given the import used details matching, when the report renders, then it shows the confidence distribution.
  4. Given two imports of the same file, when I compare their reports, then the change in match rate is shown.
Implementation notes

Aggregates data the matching path already produces — GET /lists/:id/stats plus the review-queue states and reasons — into a per-import view on ListView; the reason vocabulary is already fixed by ListReviewQueue.vue.

Edge cases & risks

"Rows uploaded" is not currently retained for ID-mode imports, since rows with no recognised ID are dropped at parse time, so the report is incomplete until LIST-23 retains parse-time failures.

MTCH-22

Record that two properties are not the same

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want to record a permanent negative assertion between two entities, so that a known-wrong pairing is never proposed again by any matcher.
Acceptance criteria
  1. Given two entities I know are distinct, when I record a negative assertion, then it is stored with who recorded it and when.
  2. Given a negative assertion exists, when the details matcher scores candidates, then that pair is excluded from consideration.
  3. Given a negative assertion exists, when the ID resolution ladder would merge the two source documents, then the merge is blocked and flagged for review.
  4. Given an assertion was recorded in error, when it is revoked, then normal matching resumes and the revocation is recorded.
Implementation notes

Needs to apply at the ingest layer, not just per list: SourceHotelDataRepo.findHuId walks a five-step resolution ladder including "shared secondary id", which is where accidental merges occur, so the assertion store must be consulted there as well as in AddressMatcher.

Edge cases & risks

Once two source records have already been merged into one huId in canonicalData6, an assertion cannot unpick them without a split operation, which does not exist today; this story is only fully useful alongside a de-merge capability.

LMGT 10 built1 partial13 proposed

Managing lists over time

A list is not a one-off artefact: it is subscribed for 365 days, exported repeatedly, compared against prices and refreshed as the underlying search changes. This epic covers what exists for living with lists today — the /lists index with its All lists / My lists / Combine sidebar, ListView's tabbed detail, inline rename, "Search this list", dynamic-list refresh with the diff panel, "Make static", entry removal and the polling manager — and then proposes the collaboration and lifecycle features the platform has no answer for: sharing, folders, notes, versioning, change logs, archiving, alerts and a list health score.

LMGT-01

Browse all lists and my lists

Built
Data analystMust
As a data analyst, I want to see every list in my organisation and filter to my own, so that I can find the one I need without asking a colleague.
Acceptance criteria
  1. Given I open /lists, when the page loads, then GET /lists returns a page of ten lists.
  2. Given I click My lists in ListsSidebarMenu.vue, when the route changes to /lists/my, then the request is filtered by createdBy.
  3. Given I scroll or page, when more lists exist, then the next page is loaded.
  4. Given my organisation has no lists, when the page renders, then it shows "There aren't any lists."
  5. Given a list belongs to another organisation, when I request it directly, then GET /lists/:id responds 403.
Implementation notes

Lists.vue with ListsManager.load(filter?: 'my'|'all', page?) against GET /lists (page size 10, createdBy filter); the sidebar offers All lists /lists, My lists /lists/my and Combine lists /lists/combine.

Edge cases & risks

Every organisation member has identical scopes, so "All lists" means every member can open, rename and delete every other member's list with no ownership protection.

LMGT-02

Read list state at a glance

Built
OTA supply managerShould
As an OTA supply manager, I want the index to tell me which lists are dynamic and which are still building, so that I do not open a list that has nothing in it yet.
Acceptance criteria
  1. Given a list with a searchQuery, when the row renders, then a Dynamic tag is shown.
  2. Given a list's status, when the row renders, then a status tag shows created, processing, ready or error.
  3. Given a list has an error, when the row renders, then the error text is shown alongside the tag.
  4. Given a list row, when it renders, then the created datetime is shown formatted for the locale.
Implementation notes

Lists.vue renders name (link), Dynamic tag when searchQuery != null, status tag, created datetime and error text; date formatting comes from utils/formatters.ts (toLocaleDateString('en-GB'|'en-US')).

Edge cases & risks

The index does not poll, so a list left in processing when the page loaded still shows processing minutes later until the user refreshes — only ListView runs ListPollingManager.

LMGT-03

Delete a list with confirmation

Built
Organisation adminShould
As an organisation admin, I want deleting a list to require confirmation, so that a mis-click does not destroy weeks of curation.
Acceptance criteria
  1. Given a list row, when I click delete, then a $confirm.require dialog asks me to confirm.
  2. Given I confirm, when the request runs, then DELETE /lists/:id is called and a success toast is shown.
  3. Given I cancel, when the dialog closes, then no request is made.
  4. Given the delete fails, when the error is handled, then an error toast is shown and the row remains.
  5. Given the list is deleted, when the index reloads, then it is removed from the current filter view.
Implementation notes

ListsManager.deleteList(id, filter)api.deleteList(listId)DELETE /lists/:id, with the shared <ConfirmDialog> and <Toast position="bottom-left"> mounted in AppLayout.vue.

Edge cases & risks

Deletion is immediate and irreversible, it destroys the review decisions recorded against the list, and any exports already generated from it keep their signed download URLs — this is the case for archiving (LMGT-21).

LMGT-04

Work through a list by tab

Partial
OTA supply managerMust
As an OTA supply manager, I want the list split into what needs my attention and what is settled, so that I can work the review queue and still see my confirmed hotels.
Acceptance criteria
  1. Given a list, when I open ListView, then tabs are shown for Needs review, Rejected / Unmatched and Confirmed Hotels.
  2. Given the tabs render, when there are items to action, then the Needs review badge shows the needs-review count plus the dynamic diff count.
  3. Given I select a tab, when the route updates, then ?tab= reflects it and a deep link restores the same tab.
  4. Given my organisation is on the paid tier, when the tabs render, then Subscribed and Not subscribed tabs are also shown.
  5. Given my organisation is not on the paid tier, when I deep-link to ?tab=subscribed, then it is not a valid route tab and I fall back to a base tab.
Implementation notes

ListView.vue with listViewValidRouteTabs(tier) in utils/accessTier.ts (base review|unmatched|hotels, plus subscribed|not_subscribed on paid) and usesRichContentSubscriptionTabs(tier). Gap: tab visibility also branches on dynamic lists, ID-based CSV imports and details-match flows, so which tabs a user sees is hard to predict.

Edge cases & risks

Users on full tier do not get the subscription tabs even though they have unrestricted rich-content access, so the most privileged tier sees fewer tabs than paid — a genuinely confusing gating outcome.

LMGT-05

Rename a list inline

Built
Travel agency consultantShould
As a travel agency consultant, I want to rename a list in place, so that the auto-generated name from a saved search becomes something my client will recognise.
Acceptance criteria
  1. Given ListHeader.vue, when I double-click the list name, then it becomes an editable field.
  2. Given I enter a new name and commit, when the request runs, then PUT /lists/:id is called and the header shows the new name.
  3. Given I enter an empty name or one longer than 200 characters, when I commit, then it is rejected and the previous name is retained.
  4. Given the request fails, when the error is handled, then the field reverts and an error toast is shown.
Implementation notes

ListDetailsManager.saveName(listId, name)api.saveListNamePUT /lists/:id; lists.name is validated 1–200 characters in the airtight schema.

Edge cases & risks

Double-click to edit is undiscoverable and has no affordance, and there is no uniqueness constraint, so a team can end up with several lists called Selected Hotels 2026-08-08.

LMGT-06

Search within a list

Built
Revenue managerShould
As a revenue manager, I want to run the normal search filters scoped to one list, so that I can find the four-star properties inside a 900-hotel programme.
Acceptance criteria
  1. Given ListHeader.vue, when I click "Search this list", then I am navigated to /search?listId=<id>.
  2. Given the search screen loads with a listId, when results are fetched, then they are scoped to that list.
  3. Given I add filters on top of the list scope, when results return, then both the scope and the filters apply.
  4. Given I clear the list scope, when results reload, then the search returns to the full index.
Implementation notes

ListHeader.vue navigates to /search?listId=; HotelManager holds listScopeId with setListScopeId() and queryToFilters(query) mapping the query string onto filters.

Edge cases & risks

The list scope is one more state that must survive the filter-chip "Clear all" action; because filters are serialised into the URL, a shared link can carry a scope to a colleague whose organisation cannot see that list.

LMGT-07

Refresh a dynamic list and review the diff

Built
Corporate travel buyerMust
As a corporate travel buyer, I want to see which new hotels now meet my saved criteria before they join my list, so that my programme changes only when I say so.
Acceptance criteria
  1. Given a dynamic list with status ready, when I click Refresh, then GET /lists/:id/diff is called.
  2. Given new hotels are found, when the diff panel renders, then it reads "Found N hotels not in the list" and lists them with the same columns as the entries table.
  3. Given I click Add selected or Add all, when the request runs, then the hotels are appended and the response reports { added, skipped }.
  4. Given the diff fails to load, when the error renders, then a Retry action is offered.
  5. Given the list is static, when I view the header, then Refresh and the diff panel are not offered.
Implementation notes

ListDiffManager (clear(), load(listId), addToList(listId, huIds), newHotels, newCount, loading, adding, error) with ListDiff.vue; the diff count also feeds the Needs review tab badge.

Edge cases & risks

A very broad saved search produces a diff the panel has to truncate, and there is no equivalent "hotels that no longer match" view, so dynamic lists only ever grow through this surface.

LMGT-08

Convert a dynamic list to a static one

Built
Corporate travel buyerShould
As a corporate travel buyer, I want to freeze a list once my programme is agreed, so that the contracted set cannot drift underneath me.
Acceptance criteria
  1. Given a dynamic list, when I click "Make static", then a confirmation dialog is shown.
  2. Given I confirm, when the request runs, then DELETE /lists/:id/search-query is called and the Dynamic tag disappears.
  3. Given the list is now static, when I view the header, then Refresh and the diff panel are no longer offered.
  4. Given the conversion succeeds, when I look for the original filters, then they are no longer recoverable from the list.
Implementation notes

ListDetailsManager.makeStatic(listId)api.makeListStaticDELETE /lists/:id/search-query, behind a $confirm.require dialog in ListHeader.vue.

Edge cases & risks

The operation is one-way and discards the searchQuery entirely, so a list made static by mistake cannot be made dynamic again — the filters would have to be reconstructed by hand.

LMGT-09

Remove an entry from a list

Built
Data analystShould
As a data analyst, I want to remove a hotel that does not belong, so that my exports and price comparisons are not polluted by it.
Acceptance criteria
  1. Given an entries row, when I click delete, then a confirmation dialog is shown.
  2. Given I confirm, when the request runs, then DELETE /lists/:id/entries/:entryId is called and the row is removed.
  3. Given the removal succeeds, when stats refresh, then items in list and hotels found both decrease appropriately.
  4. Given the list is dynamic, when I remove an entry that still matches the saved search, then it reappears on the next refresh diff.
Implementation notes

ListEntriesManager.removeEntry(listId, entryId)api.deleteListEntryDELETE /lists/:id/entries/:entryId, with the shared confirm dialog.

Edge cases & risks

On dynamic lists, removal is not durable — there is no exclusion or suppression concept — so the same unwanted hotel is re-offered by every subsequent diff.

LMGT-10

Browse a large list by infinite scroll

Built
OTA product managerShould
As an OTA product manager, I want to scroll a large list without paging controls, so that reviewing several hundred properties is one continuous motion.
Acceptance criteria
  1. Given a list with more entries than one page, when I scroll to the bottom, then the next page is fetched via GET /lists/:id/entries.
  2. Given entries are loading, when the table renders, then a skeleton table is shown rather than stale content.
  3. Given every entry is loaded, when I scroll further, then no further requests are made.
  4. Given an entry did not resolve to a hotel, when its row renders, then it shows "Hotel not found" instead of a link.
  5. Given a row is expanded, when it renders, then the HU ID and OTA ID line are shown.
Implementation notes

ListEntries.vue with ListEntriesManager (entries, total, currentPage, hasMore, load(listId, {richContent}), loadMore(...)); the same infinite-scroll pattern as HotelResultsTable.vue, matching the README rule "no stale content, show skeleton".

Edge cases & risks

There is no sort or in-table filter on entries, so finding one hotel in a 5,000-entry list means scrolling or bouncing out to "Search this list".

LMGT-11

Watch list statistics update while it processes

Built
Trial userShould
As a trial user, I want the list page to update itself while my import runs, so that I can tell the difference between slow and stuck.
Acceptance criteria
  1. Given a list in created or processing, when ListStats.vue mounts, then ListPollingManager.startPolling(listId) begins a 2.5 s loop.
  2. Given each poll, when it completes, then list details, stats, entries and review status are refreshed.
  3. Given the status becomes ready or error, when the poll returns, then polling stops.
  4. Given I leave the page, when the component unmounts, then stopPolling() is called and no further requests are made.
  5. Given the status stays processing indefinitely, when polling continues, then there is currently no timeout or stuck-list warning.
Implementation notes

ListPollingManager.startPolling/stopPolling on a 2.5 s interval, driven from ListStats.vue, which also shows the status tag, created date, items in list and hotels found.

Edge cases & risks

An indefinitely stuck list polls forever, and with several such tabs open this is a steady load against GET /lists/:id, /stats and /entries, none of which are rate-limited.

LMGT-12

Share a list with specific people

Proposed
Organisation adminMust
As an organisation admin, I want to control who can view and who can edit each list, so that a curated programme is not renamed or deleted by someone who happened to log in.
Acceptance criteria
  1. Given a list I own, when I open sharing, then I can grant view or edit to named members of my organisation.
  2. Given I have view access only, when I open the list, then rename, delete, entry removal and Make static are unavailable.
  3. Given I have no access, when I request the list, then GET /lists/:id responds 403.
  4. Given a member is removed from the organisation, when their grants are evaluated, then they lose access to every list.
  5. Given a list has no explicit grants, when it is accessed, then the current organisation-wide behaviour applies for backward compatibility.
Implementation notes

This needs the role model the platform lacks: today permissions are token scopes only (lists:manage and friends), orgs:members:manage and orgs:members:view are commented out, and membership is a flat organizations.memberIds array with identical scopes for every member.

Edge cases & risks

PATs carry lists:manage with no user identity, so a per-user permission model has to define what a service account can see — otherwise sharing is trivially bypassed through the API.

LMGT-13

Organise lists into folders and tags

Proposed
Travel agency consultantShould
As a travel agency consultant running programmes for twelve clients, I want to file lists into folders and tag them, so that /lists is navigable past the first ten rows.
Acceptance criteria
  1. Given the lists index, when I create a folder and move lists into it, then the sidebar shows the folder alongside All lists and My lists.
  2. Given I apply tags to a list, when I filter by a tag, then only lists carrying it are returned.
  3. Given I delete a folder that contains lists, when I confirm, then the lists are moved out rather than deleted.
  4. Given a list is in no folder, when I browse, then it appears under an uncategorised default.
Implementation notes

Extends ListsSidebarMenu.vue and the GET /lists query, which today supports only a createdBy filter and a page size of 10; folder and tag fields would sit on the lists document scoped by orgId.

Edge cases & risks

Without the permission model from LMGT-12, folders are purely cosmetic — anyone can move anyone's list — and tag vocabularies drift quickly without a managed tag list.

LMGT-14

Transfer list ownership

Proposed
Organisation adminCould
As an organisation admin, I want to reassign a list when someone leaves, so that "My lists" stays meaningful and orphaned lists have an owner.
Acceptance criteria
  1. Given a list, when I transfer it to another member, then createdBy ownership moves and the list appears in that member's My lists.
  2. Given a member is removed from the organisation, when they owned lists, then I am prompted to reassign before removal completes.
  3. Given a transfer, when it completes, then it is recorded with who transferred it, to whom and when.
  4. Given I am not permitted to transfer, when I attempt it, then the action is refused.
Implementation notes

lists.createdBy already exists and drives the My lists filter; removal today only guards against removing the last member ("Cannot remove the last member of an organization") and does nothing about that member's lists.

Edge cases & risks

Rewriting createdBy destroys the record of who originally created the list, which matters for the audit trail — the transfer should add a field rather than overwrite provenance.

LMGT-15

Annotate lists and entries

Proposed
OTA supply managerShould
As an OTA supply manager, I want to leave a note on a list and on individual hotels in it, so that the reason a property is in or out lives with the data rather than in email.
Acceptance criteria
  1. Given a list, when I add a description note, then it is shown on ListView and in the lists index tooltip.
  2. Given an entry, when I add an annotation, then it is displayed in the entries table and in the row expansion.
  3. Given an annotated entry is removed and later re-added, when it returns, then its annotation is restored.
  4. Given an export is generated, when annotations exist, then they are included as an optional column.
  5. Given a note exceeds the permitted length, when I save, then it is rejected with the limit stated.
Implementation notes

Fields on lists and listEntries; the entries table (ListEntries.vue) already has an expander showing the HU ID and OTA ID line, which is the natural place for an annotation. Export inclusion would extend the ExportService transforms.

Edge cases & risks

Annotations on dynamic lists attach to entries that the diff may re-create, so the annotation key should be the huId rather than the entry document id, or notes will be lost on every refresh.

LMGT-16

Version a list and roll back

Proposed
Corporate travel buyerCould
As a corporate travel buyer, I want to snapshot a list and restore an earlier version, so that I can prove what the programme looked like at contract time and undo a bad bulk action.
Acceptance criteria
  1. Given a list, when a bulk operation such as Approve all or Add all runs, then a version is captured automatically before the change.
  2. Given versions exist, when I view the history, then each shows a timestamp, the actor and the entry count.
  3. Given I restore a version, when it completes, then the list contents match that version and a new version is captured for the restore itself.
  4. Given a restore would exceed my plan's list size limit, when I attempt it, then it is refused with the limit stated.
Implementation notes

Snapshots of listEntries membership per listId, stored as id sets rather than full documents to keep them small; the trigger points are the same operations that already exist — POST /lists/:id/entries, approve/reject and diff Add all.

Edge cases & risks

Restoring membership does not restore review decisions or annotations unless those are versioned too, which would leave a restored list looking correct but having lost its provenance.

LMGT-17

See a change log for a list

Proposed
Organisation adminShould
As an organisation admin, I want a record of every addition and removal with who did it and when, so that I can answer "why is this hotel in the list" without guessing.
Acceptance criteria
  1. Given any change to list membership, when it occurs, then an entry is recorded with the actor, the action, the affected hotels and the timestamp.
  2. Given a change came from a dynamic refresh rather than a person, when it is recorded, then the actor is the system and the source is the saved search.
  3. Given a change came through a PAT, when it is recorded, then the token name is captured as the actor.
  4. Given I open the change log, when there are many entries, then it paginates and can be filtered by action type.
  5. Given a list is deleted, when the change log is queried, then it is retained for the configured period.
Implementation notes

The platform already writes a time-series accessLogs collection (timeField: time, metaField: meta, TTL 1 year) with actions such as lists:create, so list mutations can follow the same pattern; PAT principals already carry displayName: "PAT <name>".

Edge cases & risks

A dynamic refresh that adds hundreds of hotels would write hundreds of log rows unless batched into a single event with a count and a downloadable detail.

LMGT-18

Merge and subtract lists as first-class operations

Proposed
Data analystShould
As a data analyst, I want to merge one list into another or subtract one from another in place, so that I do not create a third list every time I do set arithmetic.
Acceptance criteria
  1. Given two lists, when I merge B into A, then A gains B's hotels, duplicates are collapsed and B is unchanged.
  2. Given two lists, when I subtract B from A, then hotels present in B are removed from A and the removed count is reported.
  3. Given the operation runs, when it completes, then the change log records it as a single event naming the other list.
  4. Given the target list is dynamic, when I attempt an in-place merge, then I am warned that the next refresh may reintroduce removed hotels.
  5. Given the operation would exceed the list size limit, when I confirm, then it is refused before any change is made.
Implementation notes

POST /lists/combine already computes include/exclude semantics but always creates a new list; this is the same computation applied to an existing listId, reusing POST /lists/:id/entries for the add side.

Edge cases & risks

In-place mutation is destructive and, without LMGT-16 versioning, unrecoverable; on dynamic lists subtraction is meaningless until removal suppression exists (see LMGT-09).

LMGT-19

Show list size against plan quota

Proposed
Organisation adminShould
As an organisation admin, I want to see how much of my quota each list consumes, so that I can plan a subscription rather than discover a limit mid-import.
Acceptance criteria
  1. Given a list, when ListStats renders, then it shows entries against the plan's per-list limit.
  2. Given the lists index, when it renders, then a total across lists is shown against the organisation's aggregate limit.
  3. Given I am above 90% of a limit, when the indicator renders, then it is shown in a warning state with a link to billing.
  4. Given I am on the trial tier, when the indicator renders, then the trial limits are shown rather than paid limits.
Implementation notes

Reuses the rich-content quota presentation already built in utils/billingFormatters.ts (deriveRichContentUsage, computeRichContentOverLimit, prettifyRichContentUsage) and the usage lines on BillingCurrentPlan.vue; depends on LIST-22 defining the limits.

Edge cases & risks

Dynamic lists have no fixed size — their entry count changes on refresh — so an aggregate quota that includes them will fluctuate without user action, which reads as unfair unless dynamic lists are counted at their last materialised size.

LMGT-20

Archive a list instead of deleting it

Proposed
Travel agency consultantShould
As a travel agency consultant, I want to archive last season's programme, so that my index is clean but the list is recoverable if the client comes back.
Acceptance criteria
  1. Given a list, when I archive it, then it is hidden from the default index and excluded from quota warnings.
  2. Given the archive view, when I open it, then archived lists are listed with their archive date and can be restored.
  3. Given an archived list, when I restore it, then it returns to the index with its entries and review decisions intact.
  4. Given an archived list, when a dynamic refresh would run, then it does not run until the list is restored.
  5. Given an archived list has exports, when I open it, then existing exports remain visible and expiry is unchanged.
Implementation notes

A status or archivedAt field on lists plus a filter on GET /lists, which today supports only createdBy; the confirm-dialog pattern from delete is reused, and ListPollingManager should skip archived lists.

Edge cases & risks

Archiving must interact correctly with rich-content subscriptions — hotels in an archived list may still be subscribed and billed for the remainder of their 365 days, so archiving cannot be presented as a way to stop paying.

LMGT-21

Get alerted when a dynamic list changes

Proposed
Revenue managerShould
As a revenue manager, I want to be notified when new hotels start matching my saved search, so that a new competitor opening near me does not go unnoticed for a month.
Acceptance criteria
  1. Given a dynamic list, when I enable alerts and choose a cadence, then the diff is computed on that cadence without me opening the page.
  2. Given the diff finds new hotels, when the alert fires, then I receive a notification with the count and a link to the diff.
  3. Given the diff finds nothing, when the cadence elapses, then no notification is sent.
  4. Given alerts fail repeatedly, when the failure threshold is reached, then alerts are suspended and I am told why.
  5. Given I disable alerts, when the cadence elapses, then no diff is computed for that list.
Implementation notes

Server-side scheduling via PeriodicTaskRunner computing the same result as GET /lists/:id/diff; delivery would need a second SendGrid template — today sendInvitationEmail is the only email the platform sends, and there are no welcome, export-ready or quota-warning emails.

Edge cases & risks

Computing diffs for every alerted dynamic list on a schedule is an unbounded Atlas Search workload, and a broad saved search will alert on hundreds of hotels weekly, so alerts need thresholds and digesting to avoid becoming noise.

LMGT-22

Compare two lists

Proposed
OTA product managerCould
As an OTA product manager, I want to see what is in list A but not list B and vice versa, so that I can reconcile our supply against a partner's file without exporting both.
Acceptance criteria
  1. Given two lists, when I compare them, then I see counts and rows for only-in-A, only-in-B and in-both.
  2. Given the comparison, when I select rows from either side, then I can add them to either list.
  3. Given a list is dynamic, when the comparison runs, then it is compared at its current materialised membership and this is stated.
  4. Given the lists are very large, when the comparison runs, then it is computed server-side rather than by loading both into the browser.
Implementation notes

The set logic already exists behind POST /lists/combine include/exclude; this exposes it as a read-only view with the diff-panel presentation from ListDiff.vue ("Found N hotels not in the list", Add selected / Add all) rendered in two directions.

Edge cases & risks

Comparing on huId alone hides the case where the two lists reference the same physical property through unresolved entries with huId: null, so unmatched entries need their own bucket in the comparison.

LMGT-23

Set default export settings per list

Proposed
Customer data engineerCould
As a customer data engineer, I want each list to remember the export type and format I always use, so that a repeated export is one click and my downstream job gets a consistent shape.
Acceptance criteria
  1. Given a list, when I set a default export type and format, then the Export split menu pre-selects them.
  2. Given defaults are set, when I export, then no further choices are required.
  3. Given a default type is not permitted on my tier or current tab, when I open the menu, then it is disabled with the existing explanatory tooltip.
  4. Given defaults reference the source-data type, when my organisation loses the source-data licence, then the default falls back and I am told why.
Implementation notes

Sits on top of the existing export machinery: POST /lists/:id/exports with HotelExportType (preview, full, source_data, reviews), the exports drawer and ListExportsManager; source data already requires access.licensedSources.length > 0 with the tooltip "Upgrade to a plan with the source data addon…".

Edge cases & risks

Export permissions vary by tab and tier — full, source and reviews are disabled on the hotels and not-subscribed tabs for paid organisations — so a stored default will frequently be invalid in context and must degrade visibly rather than silently.

LMGT-24

Show a list health score

Proposed
OTA product managerShould
As an OTA product manager, I want one indicator combining match rate, subscription coverage and data completeness, so that I can tell at a glance whether a list is fit to ship against.
Acceptance criteria
  1. Given a processed list, when the health card renders, then it shows a score with its three components: match rate, subscription coverage and data completeness.
  2. Given I expand a component, when it renders, then it shows the underlying counts, for example hotels with location, rating, features, reviews, images and rooms available.
  3. Given the score is low, when it renders, then the weakest component is named with the action that would improve it.
  4. Given the list is still processing, when the card renders, then the score is withheld rather than computed from partial data.
  5. Given a list of zero hotels, when the card renders, then no score is shown and the empty state explains why.
Implementation notes

Every input already exists: match rate from GET /lists/:id/stats (total vs hotels), subscription coverage from ListStatsManager.subscribedHotels/notSubscribedHotels, and completeness from the HotelMeta availability flags already rendered as the six data icons in HotelResultsTable.vue and the five in ListEntries.vue.

Edge cases & risks

A single composite score invites gaming and misreading — a list can be 100% matched and still useless if none of its hotels have images — so the components must be shown next to the score rather than behind it.

Part 6

Getting data out

EXPT 7 built4 partial11 proposed

Exports & downloads

Exports are how a list stops being a screen and becomes a file the customer can load somewhere else. Today that means POST /lists/{listId}/exports with a type of full, preview or reviews (plus source_data in the live portal), a streaming gzip pipeline that writes {listId}/{exportId}/{type}-{timestamp}.json.gz to Google Cloud Storage at constant memory, an exports drawer in ListView that polls until the file is ready, and a 15-minute v4 signed URL to fetch it. This epic covers that flow end to end — including the trial 100-hotel cap and the paid-tier "only subscribed hotels can be exported" rule — and then proposes the format, scheduling, retention and integrity work the current design leaves out.

EXPT-01

Create an export of a list

Built
Customer data engineerMust
As a customer data engineer, I want to request an export of a list in a chosen shape, so that I can load canonical hotel content into my own warehouse instead of paging the search API.
Acceptance criteria
  1. Given a list whose status is ready, when I POST /lists/{listId}/exports with type: full, then an exports record is created with status: created, the export id is returned, and processing starts asynchronously.
  2. Given I request type: preview, when the file is written, then each row conforms to HotelPreviewSchema (huId, preview{name, city, country, starRating, userRating}, meta) rather than the full HotelProfileSchema.
  3. Given I request type: reviews, when the file is written, then it contains one HotelReviewExport per review and the reviewer block is stripped, so no reviewer names leave the platform.
  4. Given my token lacks the exports:manage scope, when I post the request, then the call is rejected with 403 and no export record is created.
  5. Given I pass a type outside full|preview|reviews, when the request is validated, then it fails with a 400 schema error naming the accepted values.
  6. Given the export starts, when it runs, then one accessLogs row per huId is written with action export:create:{type}.
Implementation notes

ExportService behind POST /lists/{listId}/exports, gated by the exports:manage permission (present on both JWT login scopes and PAT scopes). Portal entry point is ListExportsManager.exportListHotels(listId, type), triggered from the Export split menu in ListHeader.vue.

Edge cases & risks

A list still in processing will export whatever entries have resolved so far, so a user who exports too early silently gets a short file; reviews exports fan out to one row per review, so a 5,000-hotel list can produce orders of magnitude more rows than the same list exported as full.

EXPT-02

Export raw source data for licensed sources

Partial
OTA product managerShould
As an OTA product manager, I want to export the per-source records behind the canonical hotel, so that I can audit where a disputed field came from and satisfy my own licensing obligations.
Acceptance criteria
  1. Given my organisation has at least one entry in access.licensedSources, when I open the Export split menu in ListHeader.vue, then Source data is selectable alongside Preview data, Full profiles and Reviews.
  2. Given my organisation has no licensed sources, when I hover the disabled Source data item, then the tooltip "Upgrade to a plan with the source data addon…" is shown and the export cannot be started.
  3. Given the export runs, when the file is produced, then it contains sourceHotelData5 documents only for the dsName values my organisation is licensed for, and records from unlicensed sources are omitted rather than nulled.
  4. Given my licence lapses between creating and downloading the export, when I request the download, then the download is refused rather than serving a file containing now-unlicensed source data.
Implementation notes

The source_data value exists in the portal's HotelExportType and is gated by AccessManager.licensedSources; the local API clone's export type union is still full|preview|reviews, so the filtering rules and the per-source licence check are only defined on the live system.

Edge cases & risks

Source-level licensing is the highest-risk export we offer — a filter bug leaks scraped Booking or TripAdvisor payloads to an organisation with no right to them; because the licence is checked at create time, a long-lived signed URL plus a 30-day file lifetime is an unbounded window unless it is re-checked at download.

EXPT-03

Stream exports to GCS at constant memory

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want exports to stream from Mongo through gzip straight into object storage, so that a million-row export does not scale API pod memory with list size.
Acceptance criteria
  1. Given an export starts, when the pipeline runs, then it is Readable.from(dbStream) → transform → toJsonArray → gzip → GoogleCloudStorageService.uploadStream with no full-result buffering.
  2. Given the upload completes, when the record is finalised, then the object key is {listId}/{exportId}/{type}-{timestamp}.json.gz, gcsFilename and gcsFileSize are persisted, and the content type is application/gzip.
  3. Given a list of 500,000 entries, when the export runs, then resident memory stays flat within the pod's normal working set rather than growing with row count.
  4. Given the Mongo cursor errors or the GCS upload aborts mid-stream, when the failure propagates, then the export record moves to status: error and the partial object is not exposed as a downloadable file.
Implementation notes

ExportService plus GoogleCloudStorageService (GCS_PROJECT_ID, GCS_BUCKET_NAME, GCS_KEY_FILE / GCS_SERVICE_ACCOUNT_KEY); uploads are non-resumable with no-cache. Bucket per docs: hotel-universe-exports-dev, europe-west1, soft-delete 7 days, lifecycle delete 30 days.

Edge cases & risks

Non-resumable uploads mean a transient GCS error restarts the whole file rather than the failed chunk, which is expensive for multi-GB review exports; a partially written object still consumes bucket storage until the lifecycle rule reaps it.

EXPT-04

Review a list's exports in the exports drawer

Built
Data analystMust
As a data analyst, I want a single panel showing every export of this list with its type, age, size and state, so that I can grab yesterday's file instead of regenerating it.
Acceptance criteria
  1. Given I open the Exports drawer from ListHeader.vue, when it loads, then GET /lists/{listId}/exports populates a row per export showing type, created date, file size and a status tag.
  2. Given an export has status: ready, when the row renders, then a download button is enabled; given created or processing, then a spinner or clock icon is shown and download is disabled.
  3. Given an export has status: error, when the row renders, then an error icon and the failure message are shown instead of a download button.
  4. Given the list has never been exported, when the drawer opens, then an empty state is shown rather than an empty table with headers only.
  5. Given the request for the export list fails, when the error is caught, then the drawer shows an error state and does not leave a permanent skeleton.
Implementation notes

components/list/ListExports.vue fed by ListExportsManager.loadExports(listId); the drawer is a PrimeVue right-hand Drawer hosted by ListView.vue. Status tags cover created, processing, ready, error and the derived Expired.

Edge cases & risks

The drawer shows every export ever made for the list with no paging or filtering, so a list exported nightly by an automation accumulates an unreadable wall of rows; file size is only known after upload, so ready rows created by an older code path can show a blank size.

EXPT-05

Poll a processing export until it is ready

Built
Travel agency consultantMust
As a travel agency consultant, I want the exports panel to update itself while my file is being built, so that I do not have to reload the page to find out whether it finished.
Acceptance criteria
  1. Given I start an export, when the response returns, then a toast confirms the export has started and the new row appears immediately in created/processing state.
  2. Given at least one export is in created or processing, when the drawer is open, then ListExportsManager.startPolling re-fetches the export list on an interval and updates the rows in place.
  3. Given every export reaches a terminal state (ready, error or expired), when the last one settles, then polling stops and no further requests are made.
  4. Given I close the drawer or navigate away from ListView, when the component unmounts, then stopPolling is called so the interval does not leak.
  5. Given the polling request fails once, when the next tick runs, then polling continues rather than terminating on a single transient error.
Implementation notes

ListExportsManager.startPolling / stopPolling, alongside the separate ListPollingManager that polls list status every 2.5 s while a list is processing. Config.pollingAppInterval is 5000 ms.

Edge cases & risks

A very large reviews export can outlive the user's session, and nothing resumes polling on the next visit beyond a manual drawer open; two open tabs on the same list double the poll rate against GET /lists/{listId}/exports, which is unrate-limited (only GET /metasearch is rate-limited today).

EXPT-06

Download a ready export via a short-lived signed URL

Built
Customer data engineerMust
As a customer data engineer, I want a direct download link for a finished export, so that my ingestion job can pull the file without proxying gigabytes through the API.
Acceptance criteria
  1. Given an export is ready and unexpired, when I GET /lists/{listId}/exports/{exportId}/download, then I receive 200 with {downloadUrl, filename, expiresAt, fileSize} where downloadUrl is a v4 signed GCS URL valid for 15 minutes.
  2. Given the export is still created or processing, when I call download, then I receive 202 and no URL, so a client can retry rather than treat it as a failure.
  3. Given the export's expiresAt has passed or the object has been reaped, when I call download, then I receive 410 and the drawer shows the Expired tag.
  4. Given the export ended in status: error, when I call download, then I receive 500 with the generic failure message rather than a broken link.
  5. Given the download succeeds, when it is recorded, then an accessLogs row is written with action export:download:{type} so it appears in Usage.vue.
  6. Given a signed URL issued more than 15 minutes ago is replayed, when GCS validates it, then the request is rejected and a fresh call to the download endpoint is required.
Implementation notes

GoogleCloudStorageService.getSignedDownloadUrl (v4); portal path is ListExportsManager.downloadExport(exportId, listId)api.downloadExportdownloadFile. Usage actions export:download:full|preview|reviews are mapped to descriptions in UsageTable.vue.

Edge cases & risks

A 15-minute URL is generous enough to be forwarded in a chat message and short enough to break a slow or resumed transfer of a multi-GB file; the signed URL carries no organisation identity, so anyone holding it within the window can fetch the data.

EXPT-07

Expire export files after 30 days

Partial
Organisation adminShould
As an organisation admin, I want export files to be deleted on a predictable schedule, so that stale copies of our hotel data do not sit in someone else's bucket indefinitely.
Acceptance criteria
  1. Given an export is created, when the record is written, then expiresAt is set to now + 30 days.
  2. Given expiresAt has passed, when the exports drawer renders that row, then the status tag reads Expired and the download button is disabled.
  3. Given the object's lifetime is reached, when the GCS lifecycle rule runs, then the file is deleted from hotel-universe-exports-dev, with a 7-day soft-delete window behind it.
  4. Given an export has expired, when I want the same data again, then I must create a new export — there is no regenerate-in-place action on the expired row.
  5. Given the object is deleted but the record still says ready, when I request the download, then the endpoint returns 410 rather than a signed URL pointing at a missing object.
Implementation notes

expiresAt = now + 30 days in ExportService; bucket lifecycle delete at 30 days with soft-delete 7 days; GoogleCloudStorageService.deleteFile exists but there is no scheduled reconciliation job that walks exports and marks records whose objects have gone.

Edge cases & risks

The database record's expiresAt and the bucket's lifecycle rule are two independent clocks — if they drift, users see either a downloadable file that is really gone or an Expired tag on an object still being billed; there is no retention override for customers with contractual archival requirements.

EXPT-08

Cap trial exports at 100 hotels and say so up front

Built
Trial userMust
As a trial user, I want to know before I export that my file will be truncated, so that I do not build a pipeline on a sample and discover the limit in production.
Acceptance criteria
  1. Given my organisation is on the trial tier, when a full or reviews export runs, then at most 100 hotels are written to the file.
  2. Given the same trial organisation exports preview, when the export runs, then no hotel cap is applied — preview exports are unlimited.
  3. Given I open a list on the trial tier, when ListView.vue renders, then the trial banner explaining that exports are "limited to the first 100 hotels on the trial plan" is shown above the tabs.
  4. Given I upgrade to a paid or full tier, when I export the same list again, then the cap no longer applies and the file contains every eligible hotel.
  5. Given a trial list contains fewer than 100 hotels, when the export completes, then no truncation message is implied and the file simply contains every hotel.
Implementation notes

The export cap mirrors ACCESS_TIER_TRIAL_LIMIT (default 100 distinct hotels) enforced by AccessTierService; the same limit drives the 402 "Upgrade to view. Contact sales at hello@ubio.ai" on GET /hotels/{huId}. Banner copy lives in ListView.vue.

Edge cases & risks

"The first 100" is defined by the export cursor's order, not by anything the user chose, so two trial exports of a changing dynamic list can return different subsets; the file itself carries no marker that it was truncated, so a downstream consumer cannot tell a capped export from a small list.

EXPT-09

Restrict paid-tier exports to subscribed hotels

Built
Organisation adminMust
As an organisation admin, I want exports on our rich-content plan to contain only hotels we have actually subscribed, so that our bill matches what we take out of the platform.
Acceptance criteria
  1. Given my organisation is on the paid tier, when I open the Export split menu on the Needs review or Rejected / Unmatched tab, then export is disabled with the message "Only subscribed hotels can be exported".
  2. Given I am on the Confirmed Hotels or Not subscribed tab, when I open the menu, then full, source_data and reviews are disabled with "Subscribe to hotels to unlock export", while preview remains available.
  3. Given I am on the Subscribed tab, when I export, then every export type permitted by my plan is enabled and the file contains only subscribed hotels.
  4. Given my organisation is on the full tier, when I export, then the subscription tabs are not used at all (usesRichContentSubscriptionTabs is false) and no subscribed-only restriction applies.
  5. Given a hotel's subscription lapses between export creation and download, when the file is served, then it still reflects the subscription state at export time, which must be visible from the export's created date.
Implementation notes

Gating logic lives in ListHeader.vue plus app/utils/accessTier.ts (usesPaidExportSubscriptionModel, usesRichContentSubscriptionTabs, listViewValidRouteTabs); subscription state comes from AccessManager.isRichContentSubscribed(huId) and POST /orgs/{orgId}/subscribed-hotels.

Edge cases & risks

The restriction is enforced in the portal's menu state; a PAT holder calling POST /lists/{listId}/exports directly bypasses the tab context entirely, so the server-side equivalent of this rule is the one that actually matters.

EXPT-10

Fail an export with an actionable message

Partial
Ubio support agentShould
As a Ubio support agent, I want a failed export to tell me something I can act on, so that I am not reduced to guessing from a customer's screenshot.
Acceptance criteria
  1. Given an export throws during the pipeline, when the record is updated, then status becomes error and error is populated.
  2. Given the customer views the failed row, when the message renders, then it reads "Export failed. Please try again later or contact support if the problem persists."
  3. Given the customer retries, when a new export is created, then it is a distinct exports record and the failed one remains visible in the drawer for reference.
  4. Given I am investigating, when I look at the failed export, then I currently have no export id, cause code or correlation id surfaced in the UI to correlate with server logs — this is the named gap.
  5. Given the failure was a permission or tier problem rather than an infrastructure fault, when the message is shown, then the customer still sees the same generic copy and cannot self-serve.
Implementation notes

The generic string is the only failure copy in ExportService; the exports record does carry an error field, and ListExports.vue renders an error icon per row. There is no export-failed email (SendGrid sends only sendInvitationEmail).

Edge cases & risks

Because every cause collapses to one message, a systemic outage and a single malformed list look identical to the customer and generate the same support ticket; swallowing the cause also hides quota and licensing rejections that the user could have fixed themselves.

EXPT-11

Offer CSV as well as JSON output

Partial
Revenue managerMust
As a revenue manager, I want the CSV the landing page promises, so that I can open my list in a spreadsheet without writing code to unpack gzipped JSON.
Acceptance criteria
  1. Given the landing page's flow section states "Export to CSV / JSON", when I export from the portal, then a format choice must be offered alongside the export type.
  2. Given I choose CSV, when the export completes, then the object is a gzipped CSV with a header row, RFC 4180 quoting, and UTF-8 with a byte-order mark so Excel opens accented hotel names correctly.
  3. Given I choose JSON, when the export completes, then behaviour is unchanged — a gzipped JSON array written by the existing toJsonArray stage.
  4. Given a full profile row contains nested arrays (features[], images[], rooms[]), when it is written as CSV, then a documented flattening rule is applied rather than embedding raw JSON blobs in a cell.
  5. Given the API clone only writes .json.gz, when a client requests CSV against an older deployment, then it receives a clear 400 naming the supported formats rather than a JSON file with a .csv name.
Implementation notes

The clone's ExportService only writes gzipped JSON and there is no CSV export path; the live portal offers CSV/JSON. CSV parsing already exists on the ingest side (csv-parse with columns: true for POST /lists/upload), so the codebase has the tabular vocabulary but not the writer.

Edge cases & risks

CSV forces an early, breaking decision about column naming and nesting that JSON let us defer; marketing already promises the format, so the gap between "Export to CSV / JSON" on /landing and a .json.gz file is a live expectation mismatch for every prospect who tries it.

EXPT-12

Export to Parquet for warehouse loads

Proposed
Customer data engineerShould
As a customer data engineer, I want a columnar Parquet export, so that loading a million canonical hotels into BigQuery or Snowflake is a single external-table statement instead of a JSON parsing job.
Acceptance criteria
  1. Given I request format: parquet, when the export runs, then row groups are written incrementally to GCS so the constant-memory property of the existing pipeline is preserved.
  2. Given the export completes, when I inspect the file, then it carries an explicit schema derived from HotelProfileSchema / HotelPreviewSchema with stable, documented column names and types.
  3. Given a nullable field such as rating.overallRating.average is absent for a hotel, when the row is written, then it is a typed null rather than an omitted column or an empty string.
  4. Given the list is large, when the export finishes, then it may be a set of part files under {listId}/{exportId}/ with a manifest, and the download endpoint returns the manifest plus signed URLs for each part.
  5. Given a consumer requests Parquet for the reviews type, when the schema is generated, then the PII-stripped HotelReviewExport shape is used, exactly as for JSON.
Implementation notes

Would slot in as an alternative writer stage between transform and gzip in ExportService, reusing the same airtight schemas that already drive docs's OpenAPI generation so the column set stays in step with the API contract.

Edge cases & risks

Parquet requires a fixed schema up front, which collides with features[] and rooms[] being open-ended and with the ~280 FEATURE_TITLES not yet being enforced; schema drift between releases will break customers' external tables unless the export records a schema version.

EXPT-13

Choose which fields an export contains

Proposed
OTA product managerShould
As an OTA product manager, I want to pick the fields I actually need, so that a 40-column feed does not arrive as a 400-field document my mapping team has to triage.
Acceptance criteria
  1. Given I create an export, when I supply a fields[] selection (for example info.name, location.*, rating.overallRating), then only those paths are written.
  2. Given I supply no selection, when the export runs, then the full schema for the chosen type is written, preserving today's behaviour.
  3. Given I select a field my tier or licence does not cover (for example a source-data path without licensedSources), when the request is validated, then it is rejected with 403 naming the offending path rather than silently dropping it.
  4. Given I select _dsNames provenance paths, when the export runs, then per-field source attribution is included so I can see which of GoogleHotels / GooglePlaces / Booking / TripAdvisor / Expedia won each field.
  5. Given I supply an unknown field path, when the request is validated, then it fails with 400 listing the valid top-level groups (refs, info, location, rating, features, images, rooms).
Implementation notes

The Mongo cursor already uses per-schema projections on the import side; the same projection idea can be pushed into the export cursor, with the allowed path list generated from the airtight schemas rather than hand-maintained.

Edge cases & risks

Field selection multiplies the number of distinct file shapes support has to reason about, and a projection that omits huId produces a file that cannot be joined to anything; deep projections into rooms[] and features[] need array-element semantics defined or the output is ambiguous.

EXPT-14

Flatten nested records for tabular consumers

Proposed
Data analystShould
As a data analyst, I want a flattening option that turns nested hotel documents into rows, so that I can join amenities and rooms in SQL without an unnest step.
Acceptance criteria
  1. Given I enable flattening, when the export runs, then scalar nested fields are emitted as dotted columns (location.city, rating.overallRating.average, info.checkIn.from).
  2. Given I choose explode: features, when the export runs, then one row per hotel-feature pair is written with the hotel's key columns repeated, so amenity analysis is a plain GROUP BY.
  3. Given I choose explode: rooms or explode: images, when the export runs, then the same one-row-per-child rule applies and the child's identifier column is present.
  4. Given flattening is off, when the export runs, then output is byte-for-byte equivalent to today's nested JSON.
  5. Given an exploded export is requested for a list at the trial 100-hotel cap, when it runs, then the cap still counts hotels, not rows, so the file may contain far more than 100 rows.
Implementation notes

A transform stage in the existing streaming pipeline; the category vocabulary is already fixed (16 HotelFeature categories, 12 sub-types, the tags enum), which makes a stable exploded schema achievable.

Edge cases & risks

Exploding a 500,000-hotel list on features can produce tens of millions of rows and a file far larger than customers expect from a "hotel list"; repeating key columns per child row inflates uncompressed size dramatically, which interacts badly with a 15-minute download window.

EXPT-15

Schedule recurring exports

Proposed
Customer data engineerShould
As a customer data engineer, I want a list to export itself on a schedule, so that my nightly load does not depend on someone clicking Export in a browser.
Acceptance criteria
  1. Given a list, when I create a schedule with a cadence (daily, weekly, monthly), a time, a timezone, a type and a format, then the schedule is persisted against the list and the organisation.
  2. Given a schedule fires, when the export completes, then it appears in the exports drawer tagged as scheduled, with the schedule name visible.
  3. Given the list is a dynamic list, when the schedule fires, then the search query is re-run first so the export reflects current membership, matching the behaviour of the Refresh button.
  4. Given three consecutive scheduled runs fail, when the fourth would fire, then the schedule is paused and the organisation admin is notified rather than accumulating failures silently.
  5. Given my tier does not permit exports of this type, when the schedule fires, then the run is skipped with a recorded reason and no partial file is produced.
Implementation notes

Would build on the PeriodicTaskRunner.schedule(task, interval, name) pattern already used by the scrapers service, with schedules stored alongside lists and executed by the same ExportService entry point. Retention rules (EXPT-07) must apply per run, not per schedule.

Edge cases & risks

Scheduled exports quietly convert exports from a human action into steady-state load — 200 customers with nightly exports is a nightly Mongo scan storm; combined with the 30-day lifecycle it also multiplies bucket cost, and every run of a trial-tier schedule silently produces a 100-hotel file.

EXPT-16

Export only what changed since a timestamp

Proposed
Customer data engineerMust
As a customer data engineer, I want to export only the hotels that changed since my last pull, so that a daily refresh moves megabytes instead of gigabytes.
Acceptance criteria
  1. Given I create an export with since: <ISO timestamp>, when it runs, then only hotels whose canonicalData6.updatedAt is later than that timestamp are written.
  2. Given hotels have been added to or removed from the list since that timestamp, when the export runs, then additions appear as rows and removals appear in a removed[] section or as tombstone rows keyed on huId.
  3. Given the export completes, when I read its metadata, then it reports a watermark timestamp I should pass as since on my next call, so pulls chain without gaps.
  4. Given since is older than the platform's change-history horizon, when the request is validated, then the API responds telling me to take a full snapshot instead of returning a silently incomplete delta.
  5. Given nothing changed, when the export completes, then it succeeds with zero rows rather than failing, and the watermark still advances.
Implementation notes

canonicalData6 already carries updatedAt, and GET /lists/{listId}/diff already computes membership changes for dynamic lists; a delta export is those two signals combined and written through the same streaming pipeline. Pairs directly with the CDC feed proposed in DELV.

Edge cases & risks

updatedAt moves whenever the merge re-runs, even if no field actually changed, so scrapers on a 30-day staleness cycle will make deltas look far larger than the real change volume; deletions are the hard part, because a hotel dropping out of a dynamic list is not the same event as a canonical record being removed.

EXPT-17

Save export presets per list

Proposed
OTA supply managerCould
As an OTA supply manager, I want to save the combination of type, format, fields and flattening I always use, so that my colleagues produce the same file shape I do.
Acceptance criteria
  1. Given I have configured an export, when I choose "Save as preset" and name it, then the preset is stored against the organisation and appears in the Export menu for every member.
  2. Given a preset exists, when I select it, then all of its options are applied in one click and the export can be started without further configuration.
  3. Given a preset references a field or type my tier no longer permits, when I select it, then the unavailable options are disabled with the same tooltips used elsewhere, rather than the export failing after submission.
  4. Given a preset is edited, when it is saved, then existing scheduled exports referencing it pick up the new definition on their next run and the change is attributed to the editor.
  5. Given a preset is deleted while a schedule uses it, when I confirm, then I am warned which schedules will be affected before the deletion completes.
Implementation notes

Presets are a small org-scoped collection consumed by the ListHeader.vue export split menu; there are no per-user roles today (every member has identical scopes), so presets are shared org-wide by construction.

Edge cases & risks

With no roles, any member can redefine a preset that another member's downstream pipeline depends on; presets also freeze field selections that schema changes can invalidate, so they need validation at use time rather than only at save time.

EXPT-18

Notify me when an export is ready

Proposed
Travel agency consultantShould
As a travel agency consultant, I want an email or Slack message when my export finishes, so that I can close the tab and get on with something else.
Acceptance criteria
  1. Given I start an export, when I tick "Notify me when ready", then on completion I receive a message containing the list name, export type, file size and a link back to the exports drawer.
  2. Given the export fails, when the notification is sent, then it says so plainly and links to the failed row rather than to a broken download.
  3. Given my organisation has configured a Slack destination, when the export completes, then the same notification is posted there as well as, or instead of, by email.
  4. Given the notification contains a link, when it is followed, then it points at the portal and requires sign-in — a raw 15-minute signed URL is never embedded in an email.
  5. Given the notification cannot be delivered, when the send fails, then the export itself remains ready and downloadable — notification failure must not affect export state.
Implementation notes

SendGrid is already wired (SENDGRID_API_KEY, SENDER_EMAIL) but sends exactly one message type today, sendInvitationEmail; this is the second template plus a notification preference on the user or organisation. Slack delivery would ride on the outbound-webhook work in DELV.

Edge cases & risks

The current invitation flow deletes the invitation if sending throws — that coupling must not be copied here, or a mail outage would destroy finished exports; scheduled exports plus per-run notifications is a fast route to an inbox nobody reads.

EXPT-19

Configure export retention

Proposed
Organisation adminCould
As an organisation admin, I want to set how long our export files are kept, so that I can meet a 7-day deletion policy or a 12-month audit requirement without opening a support ticket.
Acceptance criteria
  1. Given I open organisation settings, when I set a retention period within the range my plan allows, then new exports use it for expiresAt instead of the 30-day default.
  2. Given I shorten retention, when the change is saved, then existing exports older than the new period are expired and their objects scheduled for deletion.
  3. Given I request a period longer than my plan permits, when I save, then the change is rejected with the allowed maximum stated.
  4. Given an export is deleted early, when I view the drawer, then the row remains with an Expired tag and the deletion reason, so history is not silently rewritten.
  5. Given a legal-hold flag is set on an export, when retention would expire it, then the file is preserved and the override is recorded.
Implementation notes

Requires the record's expiresAt and the GCS lifecycle rule to be reconciled by an actual job rather than assumed to agree; GoogleCloudStorageService.deleteFile already exists for the delete path.

Edge cases & risks

Per-organisation retention cannot be expressed as one bucket-wide lifecycle rule, so deletion has to become application-driven — meaning a failed job silently retains customer data past its stated policy, which is worse than a fixed 30 days.

EXPT-20

Download very large exports reliably

Proposed
DeveloperShould
As a developer, I want range requests, resumability and a checksum on export downloads, so that a dropped connection at 90% does not cost me the whole transfer or leave me with a corrupt file.
Acceptance criteria
  1. Given a ready export, when I request the download, then the response includes a content hash (CRC32C or MD5 from GCS) alongside filename, fileSize and expiresAt.
  2. Given I verify the downloaded file against that hash, when it matches, then I can treat the file as complete without decompressing it first.
  3. Given my transfer is interrupted, when I resume with a Range header against the signed URL, then the download continues from the last received byte.
  4. Given the signed URL expires mid-transfer, when I re-request the download endpoint, then I receive a fresh URL for the same object and can resume rather than restart.
  5. Given the export was written as multiple parts, when I request the download, then a manifest listing every part with its size and hash is returned so a client can fetch them in parallel.
Implementation notes

GCS supports range reads and exposes object hashes; getFileSize is already used, so surfacing the hash is a small extension of GoogleCloudStorageService. Uploads are currently non-resumable, which is a separate reliability gap on the write side.

Edge cases & risks

A 15-minute URL is the binding constraint for large files on slow links, and re-issuing URLs mid-transfer needs the licence and tier checks re-run each time, not just at first issue.

EXPT-21

Estimate an export before committing to it

Proposed
Trial userCould
As a trial user, I want to see how many hotels and roughly how large a file I am about to generate, so that I do not wait ten minutes for something the trial cap will truncate anyway.
Acceptance criteria
  1. Given I open the Export menu, when I pick a type, then an estimate of row count and approximate compressed size is shown before I confirm.
  2. Given I am on the trial tier and the list exceeds 100 hotels, when the estimate renders for full or reviews, then it states plainly that the file will contain 100 hotels and links to billing.
  3. Given I am on the paid tier and some hotels are not subscribed, when the estimate renders, then it separates eligible from ineligible hotels so the shortfall is explained before the export runs.
  4. Given the estimate cannot be computed (for example a dynamic list still processing), when the menu renders, then it shows "Estimate unavailable" and still allows the export.
  5. Given the finished file differs materially from the estimate, when the export completes, then the actual row count and size are recorded on the export record for comparison.
Implementation notes

GET /lists/{listId}/stats already returns {total, hotels} (entries versus resolved), and ListStatsManager exposes needsReview, unmatched, subscribedHotels and notSubscribedHotels — the estimate is those numbers multiplied by a per-type average row size.

Edge cases & risks

Review counts are not part of list stats, so a reviews estimate needs its own aggregation and will be the least accurate of the three; an estimate that undershoots badly is worse than none, because customers will size infrastructure against it.

EXPT-22

Keep an auditable history of exports and downloads

Proposed
Organisation adminShould
As an organisation admin, I want to see who exported what and who downloaded it, so that I can answer a security question about where our licensed hotel data went.
Acceptance criteria
  1. Given an export is created, when I view export history, then I see the creating principal (user or PAT display name, for example PAT <name>), the type, format, row count and creation time.
  2. Given an export is downloaded, when I view its history entry, then each download is listed with the principal and timestamp, not just an aggregate count.
  3. Given a PAT created the export, when I view the entry, then the token's name is shown so the automation can be identified without reverse-engineering the log.
  4. Given I filter history by date range and export type, when the results render, then they can themselves be exported as CSV for an audit pack.
  5. Given a member is removed from the organisation, when I view history, then their past exports remain attributed rather than being anonymised or disappearing.
Implementation notes

accessLogs is a Mongo time-series collection keyed on {time, principalId, huId, meta:{orgId, action}} with a one-year TTL and already records export:create:{type} and export:download:{type}; Usage.vue renders it only as unique-hotel counts per action, so the per-principal detail exists but is never surfaced.

Edge cases & risks

The one-year TTL caps how far back an audit can reach, and principalId alone does not resolve to a human name after a member is removed; the time-series collection is optimised for aggregation, so a principal-level history view needs its own access pattern rather than reusing the /stats aggregation.

DELV 0 built3 partial17 proposed

Continuous delivery of data (webhooks, feeds, warehouses)

The landing page's flow section ends with "Export to CSV / JSON or push diffs to a webhook", but no outbound webhook feature exists: the only webhook in the platform is the inbound Stripe endpoint at POST /billing/webhook, and the only change signal a customer can get is a pull-based GET /lists/{listId}/diff behind the portal's Refresh button. This epic is therefore mostly Proposed by necessity — it defines the push side of the product: an event catalogue, signed and retried HTTP delivery, a delivery log, and the bulk-plus-changelog patterns that let a partner keep a mirror of Hotel Universe in their own warehouse, bucket, or stream.

DELV-01

Receive Stripe subscription events

Partial
Ubio finance opsMust
As Ubio finance ops, I want subscription state changes from Stripe to update an organisation's tier automatically, so that paying customers get access without anyone touching the database.
Acceptance criteria
  1. Given Stripe posts to POST /billing/webhook with a valid stripe-signature, when the event is one of checkout.session.completed, customer.subscription.updated, customer.subscription.deleted or invoice.payment_failed, then the organisation's billing block and tier are updated.
  2. Given the subscription status is active, when the tier is derived, then the organisation becomes full; otherwise it falls back to trial.
  3. Given an event type we do not handle arrives, when it is processed, then it is ignored and acknowledged.
  4. Given handling throws, when the response is written, then the endpoint still returns 200 and the error is swallowed — so a failed tier update is invisible and Stripe never retries. This is the named gap.
  5. Given the event carries a stripeCustomerId, when the organisation is looked up, then the lookup is a full collection scan (findAll().find(...)), which will not hold as the organisation count grows.
Implementation notes

Stripe apiVersion 2025-02-24.acacia; plans pro|pro_plus|ultra map to STRIPE_PRICE_PRO|PRO_PLUS|ULTRA. A code TODO records that all three paid plans currently grant identical access. The live portal layers per-product plans (smartFeed, metasearch, richContent) and tiers experience|consume|deliver|grow|more on top.

Edge cases & risks

Always-200 plus swallowed errors means a payment succeeding while the tier update fails leaves a paying customer on trial with no alert; there is no idempotency key handling, so a Stripe retry after a partial update can reapply state.

DELV-02

Detect list changes on demand

Partial
Data analystMust
As a data analyst, I want to see which hotels now match my saved search but are not yet in my list, so that I can keep a dynamic list current.
Acceptance criteria
  1. Given a dynamic list (one carrying a searchQuery) with status ready, when I press Refresh in ListHeader.vue, then GET /lists/{listId}/diff returns the hotels that match the query but are absent from the list.
  2. Given new hotels are found, when ListDiff.vue renders, then it shows "Found N hotels not in the list" with Add selected / Add all, calling POST /lists/{listId}/entries and reporting {added, skipped}.
  3. Given the list is static, when I view it, then no diff panel or Refresh action is offered.
  4. Given the diff request fails, when the error is caught, then an error state with Retry is shown rather than an empty panel.
  5. Given nobody opens the portal, when the underlying data changes, then nothing is delivered anywhere — the diff is strictly pull-based, which is the gap against the "push diffs to a webhook" claim.
Implementation notes

ListDiffManager.load(listId) / addToList(listId, huIds); membership changes only — a diff says nothing about hotels already in the list whose canonical record changed.

Edge cases & risks

Re-running a saved Atlas Search query is expensive on large radii and is triggered by a button with no throttle; a diff that returns thousands of hotels is truncated in the UI, so "Add all" and what the user can see are not the same set.

DELV-03

Collect first-party product events

Partial
Ubio platform engineerShould
As a Ubio platform engineer, I want an internal event stream of what users do in the portal, so that we have the plumbing to build customer-facing delivery on top of rather than inventing it twice.
Acceptance criteria
  1. Given a user acts in the portal, when the event is tracked, then PortalTrackingManager.track(event, pageOrMetadata?, metadata?) buffers it and flushes to POST /portal-events on a 5 s interval.
  2. Given the page is being hidden, when pagehide fires, then the buffer is flushed with keepalive so in-flight events are not lost on navigation.
  3. Given an export is created or downloaded, when the action completes, then a corresponding row lands in the accessLogs time-series collection with action export:create:{type} or export:download:{type}.
  4. Given a customer wants any of these events delivered to their own systems, when they look for a way to subscribe, then none exists — the stream is internal-only, which is the named gap.
Implementation notes

POST /portal-events plus the accessLogs time-series collection (timeField: time, metaField: meta, hourly granularity, one-year TTL). The tracked event catalogue is broad (page_view, hotel_search_executed, list_saved:*, metasearch_executed, subscription_upgrade_started, and so on).

Edge cases & risks

Portal telemetry is best-effort and lossy by design, so it is fine for analytics but unsuitable as the source of truth for a customer-facing delivery guarantee; accessLogs is the more durable stream but is keyed for aggregation, not for ordered replay.

DELV-04

Register and manage outbound webhook endpoints

Proposed
DeveloperMust
As a developer, I want to register an HTTPS endpoint that Hotel Universe posts events to, so that my systems react to changes instead of polling a list every hour.
Acceptance criteria
  1. Given I open a Webhooks screen in settings, when I add an endpoint URL, then it is saved against my organisation with a generated signing secret shown once, in the same pattern as POST /tokens returning a pat_ secret.
  2. Given an endpoint is created, when I send a test event, then the delivery result — status code, latency, response body excerpt — is shown immediately.
  3. Given an endpoint is not HTTPS, or resolves to a private or link-local address, when I try to save it, then it is rejected with an explanatory error.
  4. Given I need to rotate a secret, when I request rotation, then both the old and new secrets are accepted for a grace period so I can deploy without dropping deliveries.
  5. Given I disable an endpoint, when events fire, then nothing is delivered and no retry backlog accumulates for it.
  6. Given a member is removed from the organisation, when endpoints they created are inspected, then they remain active and org-owned rather than being deleted with the user.
Implementation notes

Mirrors the existing accessTokens model — org-scoped records with a hashed secret, created under an exports:manage-style scope. There are no per-user roles today, so any member can manage any endpoint; a webhooks:manage permission would need adding to the declared scope list.

Edge cases & risks

Customer-supplied URLs are an SSRF surface, which matters more here than usual given the platform already has unauthenticated endpoints (/scrapers/*, POST /sources, /img/resize); without per-user roles, endpoint management is effectively org-wide admin.

DELV-05

Sign webhook payloads and prevent replay

Proposed
DeveloperMust
As a developer, I want every delivery signed and timestamped, so that my receiver can prove a payload came from Hotel Universe and reject a replayed one.
Acceptance criteria
  1. Given an event is delivered, when the request is sent, then it carries a signature header containing an HMAC-SHA256 of timestamp + '.' + body computed with my endpoint's secret.
  2. Given I verify the signature, when I compute it with the same secret, then it matches byte for byte, and the documentation states the exact canonical string being signed.
  3. Given the timestamp is older than a documented tolerance (for example five minutes), when my receiver checks it, then it can safely reject the request as a replay.
  4. Given a secret has just been rotated, when a delivery is made during the grace period, then both signatures are supplied so either secret verifies.
  5. Given every delivery, when it is sent, then it carries a unique, stable event id so my receiver can deduplicate on at-least-once delivery.
Implementation notes

Same shape as the inbound stripe-signature verification already implemented for POST /billing/webhook, so the platform has a working reference for both signing and tolerance checking.

Edge cases & risks

At-least-once delivery makes receiver-side idempotency mandatory, and customers routinely get it wrong; signing the raw body means the delivery pipeline must not re-serialise the payload after signing, which is a classic source of "signature mismatch" support tickets.

DELV-06

Retry failed deliveries with backoff and a dead-letter view

Proposed
Customer data engineerMust
As a customer data engineer, I want deliveries retried automatically when my endpoint is down, so that a ten-minute deploy does not create a permanent hole in my data.
Acceptance criteria
  1. Given a delivery returns a 5xx, a timeout or a connection error, when the retry policy runs, then it is retried on an exponential backoff schedule with jitter over a documented window (for example 24 hours).
  2. Given a delivery returns a 4xx other than 429, when the result is recorded, then it is treated as a permanent failure and not retried, because the receiver rejected the payload rather than being unavailable.
  3. Given a delivery returns 429 with Retry-After, when the next attempt is scheduled, then that header is honoured.
  4. Given the retry window is exhausted, when the delivery finally fails, then the event moves to a dead-letter view where I can inspect and manually redrive it.
  5. Given an endpoint fails continuously for a documented period, when the threshold is crossed, then it is auto-disabled and the organisation is notified, so we stop hammering a dead host.
  6. Given retries are in flight, when subsequent events fire for the same endpoint, then per-endpoint ordering guarantees are stated explicitly in the docs rather than left implicit.
Implementation notes

The API already has rate-limiter-flexible with Redis when REDIS_URL is set, plus the scrapers service's PeriodicTaskRunner and interruptible Sleepbox — between them there is enough scheduling machinery to run a retry queue without new infrastructure.

Edge cases & risks

Retries plus fan-out is how one slow customer endpoint becomes a platform-wide backlog; the existing PendingPromises fire-and-forget pattern used for list processing is explicitly not durable enough for delivery guarantees.

DELV-07

Inspect and replay individual deliveries

Proposed
DeveloperShould
As a developer, I want to see the exact payload and response for a delivery and replay it, so that I can debug my receiver without asking Ubio support to dig through logs.
Acceptance criteria
  1. Given deliveries have occurred, when I open the delivery log for an endpoint, then I see event type, event id, timestamp, attempt count, HTTP status and duration per delivery.
  2. Given I open a delivery, when the detail renders, then I can see the exact request body and headers sent (with the signature) and the truncated response body received.
  3. Given a delivery failed or I have fixed a bug, when I press Replay, then the identical payload is re-sent with the same event id, so a correctly built receiver deduplicates it.
  4. Given a delivery is older than the log retention period, when I look for it, then the retention limit is stated in the UI rather than the row silently being absent.
  5. Given the payload contains reviews, when it is stored in the log, then reviewer identity is stripped exactly as HotelReviewExport does, so the log does not become a PII store.
Implementation notes

Would sit next to the exports drawer conceptually — a right-hand Drawer with polling — and could reuse the accessLogs time-series pattern for the delivery records themselves.

Edge cases & risks

Delivery logs holding full payloads are large and sensitive; storing them for long enough to be useful conflicts with retention promises, so payload bodies likely need a shorter TTL than the delivery metadata.

DELV-08

Subscribe an endpoint to only the events it wants

Proposed
OTA product managerShould
As an OTA product manager, I want each endpoint to receive only specific event types, and optionally only for specific lists, so that my pricing service is not woken by content changes it ignores.
Acceptance criteria
  1. Given I configure an endpoint, when I select event types from the catalogue, then only those types are delivered to it.
  2. Given I scope an endpoint to one or more lists, when an event fires for a hotel outside those lists, then it is not delivered.
  3. Given I add a filter such as "only when the price gap exceeds 5%", when events are evaluated, then non-matching events are recorded as filtered rather than as failed deliveries.
  4. Given I select an event type my tier does not include (for example metasearch price events without metasearch access), when I save, then it is rejected with the same upgrade messaging used elsewhere in the portal.
  5. Given no event types are selected, when I save, then the endpoint is rejected rather than silently receiving nothing.
Implementation notes

Filtering is cheapest applied at fan-out time against the subscription record; list scoping can reuse listEntries membership, and tier checks reuse AccessManager.hasMetasearchAccess() semantics on the server side.

Edge cases & risks

List-scoped filtering on a dynamic list means membership itself moves, so an event can be in or out of scope depending on when it is evaluated; overly clever filter expressions become an evaluation cost on every event for every endpoint.

DELV-09

Emit events when a list changes

Proposed
Customer data engineerMust
As a customer data engineer, I want an event when hotels are added to or removed from a list, so that my mirror of the list stays correct without a nightly full pull.
Acceptance criteria
  1. Given hotels are added to a list by CSV upload, POST /lists/fromIds, POST /lists/{listId}/entries or POST /lists/combine, when the list reaches ready, then a list.entries.added event is emitted carrying the list id, the huIds and the origin.
  2. Given entries are removed via DELETE /lists/{listId}/entries/{entryId}, when the removal commits, then a list.entries.removed event is emitted.
  3. Given a dynamic list's query produces new matches, when the refresh runs, then the same membership events are emitted without anyone opening the portal.
  4. Given a list's processing fails, when the status becomes error, then a list.failed event is emitted with the error, so a partner is not left waiting for a completion that never comes.
  5. Given a bulk operation adds 50,000 hotels, when events are emitted, then they are batched into a bounded number of deliveries with a documented maximum payload size, not one delivery per hotel.
Implementation notes

The state machine already exists — lists.status moves created → processing → ready|error — and ListPollingManager proves the transitions are observable; emitting on the same transitions is the natural hook. The membership computation is the same one behind GET /lists/{listId}/diff.

Edge cases & risks

List processing runs through PendingPromises fire-and-forget, so an event emitted inside that path can be lost on pod shutdown unless it is written transactionally with the state change; unresolved entries (huId: null) need a stated rule about whether they generate events at all.

DELV-10

Emit a change-data-capture feed keyed on huId

Proposed
Customer data engineerMust
As a customer data engineer, I want an ordered stream of canonical record changes keyed on huId with a monotonic version, so that I can apply updates to my copy idempotently and know I have not missed one.
Acceptance criteria
  1. Given a canonical record is recomputed and any field actually changes, when the change commits, then a hotel.updated event is emitted with the huId, a monotonically increasing version, and the changed field groups.
  2. Given I receive two events for the same huId out of order, when I apply them by version, then the higher version always wins and the lower one can be safely discarded.
  3. Given a merge re-runs but produces an identical document, when the change is evaluated, then no event is emitted, so the feed reflects real changes rather than scraper cadence.
  4. Given the event includes provenance, when I inspect it, then the _dsNames entries for the changed groups tell me which source won each field.
  5. Given a hotel is removed or merged into another huId, when the change commits, then a tombstone or hotel.merged event carries both ids so my mirror can follow the merge.
  6. Given I have missed events while offline, when I reconnect, then I can request everything after a known version rather than starting a full resync.
Implementation notes

HotelDataUpdateService.combineData already walks FIELD_GROUPS = [refs, info, location, rating, features, images, rooms] in DS_NAMES priority order and records provenance in _dsNames, so a change set falls out of the merge naturally; canonicalData6.updatedAt gives an ordering hint but is not a version.

Edge cases & risks

huId is only deterministic for GoogleHotels (sha1('31b54e6e4b#' + dsHotelId)); everything else resolves through the ladder in SourceHotelDataRepo.findHuId, so late entity resolution genuinely does re-key hotels — merge events are not an edge case, they are routine, and any consumer without merge handling will accumulate duplicates.

DELV-11

Emit subscription and expiry events

Proposed
Organisation adminShould
As an organisation admin, I want to be told before our rich-content subscriptions expire, so that our downstream feeds do not go stale without warning.
Acceptance criteria
  1. Given a hotel's 365-day rich-content subscription is approaching its end, when the configured lead time is reached (for example 30, 7 and 1 days), then a subscription.expiring event is emitted with the huIds and the expiry date.
  2. Given a subscription lapses, when the expiry passes, then a subscription.expired event is emitted and the affected hotels are listed.
  3. Given a metasearch allowance is nearly exhausted, when the threshold is crossed, then an allowance.threshold event is emitted carrying the same signal the portal shows as "You've reached your metasearch allowance…".
  4. Given a subscription is cancelled but access continues to period end, when the event is emitted, then it distinguishes "cancels on <date>" from "already inactive".
  5. Given no endpoint subscribes to these events, when they fire, then they are still recorded so the equivalent in-portal banner and any future email can be driven from the same source.
Implementation notes

AccessManager already exposes richContentExpiresAt and richContentExpiresInDays(), and BillingCurrentPlan.vue already renders "Renews <date>" / "Cancels on <date> — access continues until then"; the events are those computed states published rather than only rendered.

Edge cases & risks

Expiry is per hotel, not per organisation, so a large list produces a long tail of individually expiring hotels — events must be aggregated by day or the feed becomes noise; the tier mapping is currently binary (active → FULL, else TRIAL), so nuanced per-product expiry only exists on the live system.

DELV-12

Emit an event when an export is ready

Proposed
DeveloperShould
As a developer, I want a webhook when an export finishes, so that my loader starts immediately instead of polling GET /lists/{listId}/exports on a timer.
Acceptance criteria
  1. Given an export reaches status: ready, when the event is emitted, then it carries the list id, export id, type, format, gcsFileSize and expiresAt.
  2. Given my receiver wants the file, when it handles the event, then it calls GET /lists/{listId}/exports/{exportId}/download with its own credentials — the event never embeds a signed URL.
  3. Given an export ends in status: error, when the event is emitted, then an export.failed event carries the export id so my pipeline can alert rather than hang.
  4. Given delivery is retried after the file has expired, when my receiver requests the download, then it receives 410 and can distinguish "expired" from "failed".
  5. Given a scheduled export produced the file, when the event is emitted, then it identifies the schedule so I can route different feeds differently.
Implementation notes

Directly replaces the polling loop in ListExportsManager.startPolling for API consumers, and is the same trigger point as the ready-notification email proposed in EXPT-18.

Edge cases & risks

A 15-minute signed URL and a 24-hour retry window are incompatible timescales, which is exactly why the URL must not travel in the payload; late-delivered events pointing at expired files will be a recurring support theme unless the 410 semantics are documented prominently.

DELV-13

Emit price threshold events

Proposed
Revenue managerShould
As a revenue manager, I want to be told the moment an OTA undercuts my direct rate by more than my threshold, so that I can react inside the booking window rather than after it.
Acceptance criteria
  1. Given I define a rule for a hotel or a list (for example "any OTA more than 5% below our direct rate for stay dates in the next 30 days"), when a price snapshot crosses that threshold, then a price.threshold.crossed event is emitted.
  2. Given the event fires, when I read the payload, then it names the hotel, the stay date, the advertiser, both prices, the gap in absolute and percentage terms, and the snapshot time.
  3. Given the condition remains true across consecutive snapshots, when subsequent snapshots are evaluated, then a cooldown suppresses repeats until the condition clears or materially worsens.
  4. Given the gap closes, when the next snapshot is evaluated, then a price.threshold.cleared event is emitted so an open alert can be resolved.
  5. Given my organisation lacks metasearch access, when I try to create the rule, then it is rejected with the standard upgrade path to Billing rather than created and never fired.
Implementation notes

The comparison logic already exists in the portal as HotelPriceInsights.vue (pricing action raise/soften/hold, underpriced and overpriced date lists with gapAbs/gapPct) and CompetitorPriceMatrix.vue; this is that evaluation moved server-side and run on snapshot arrival. Prices come from GET /metasearch via METASEARCH_SERVICE_URL and from /direct-pricing/*.

Edge cases & risks

Metasearch is rate-limited per principal and takes 2–5 s per call, so threshold evaluation cannot be a synchronous fan-out over a large list; alert volume is the real risk — a 500-hotel comp set across a 365-day lookahead can generate thousands of crossings a day without aggressive aggregation.

DELV-14

Emit an event when rows need human review

Proposed
OTA supply managerShould
As an OTA supply manager, I want a notification when uploaded rows land in the review queue, so that my ops team clears them the same day instead of discovering them a week later.
Acceptance criteria
  1. Given a CSV upload finishes matching, when any row is in state needs_review, then a match.review.needed event is emitted with the list id and the count.
  2. Given rows are unmatched, when the event is emitted, then the reasons are included using the existing vocabulary: missing_matching_data, id_lookup_not_found, no_candidates_above_threshold, all_candidates_rejected.
  3. Given the queue is cleared by approving or rejecting entries, when the last row is resolved, then a match.review.cleared event is emitted.
  4. Given every row matched confidently, when processing completes, then no review event is emitted at all.
  5. Given the same list is re-uploaded, when new review rows appear, then the event references the new rows only, not the previously resolved ones.
Implementation notes

ListReviewQueue.vue and ListReviewManager already model states confirmed | needs_review | unmatched with per-signal confidence for overall, name, address, postal code and geo; the matcher accepts at confidence ≥ 0.7 (nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3).

Edge cases & risks

The known matcher weakness — "123 Main St" versus "124 Main St" not being well distinguished — means a confidently confirmed row can still be wrong, and no event fires for those; a large supply file can put tens of thousands of rows into review at once, so the event must carry counts and a link, never the rows themselves.

DELV-15

Publish a bulk snapshot plus incremental changelog

Proposed
Customer data engineerMust
As a customer data engineer, I want a documented "snapshot then changelog" contract, so that I can bootstrap a mirror once and then keep it fresh forever without ever taking a full copy again.
Acceptance criteria
  1. Given I start a new integration, when I request a snapshot for a list, then I receive a full export plus the changelog version that snapshot is consistent as of.
  2. Given I have applied the snapshot, when I read the changelog from that version onward, then I receive every subsequent change with no gap and no duplicate beyond at-least-once semantics.
  3. Given I fall behind the changelog's retention horizon, when I request an old version, then I receive a clear instruction to take a new snapshot rather than a partial stream.
  4. Given a snapshot is taken while changes are in flight, when I reconcile, then applying the changelog from the recorded version converges to the same state regardless of ordering.
  5. Given I want to verify my mirror, when I request a reconciliation digest for a list, then I receive per-huId versions or a checksum I can compare without re-downloading the data.
Implementation notes

Combines the existing export pipeline (snapshot) with the CDC feed in DELV-10 (changelog); the platform's own R&D documentation already describes streaming export and buffered bulk writes as core capabilities, so both halves exist as primitives.

Edge cases & risks

The snapshot/changelog boundary is where mirrors silently diverge, and divergence is invisible without the digest in criterion 5; changelog retention directly determines how long a customer's pipeline can be broken before a costly full resync is required.

DELV-16

Backfill or replay a date range

Proposed
DeveloperShould
As a developer, I want to replay all events for a date range into my endpoint, so that I can recover from an outage on my side without a support ticket.
Acceptance criteria
  1. Given my endpoint was down, when I request a replay for a start and end time and a set of event types, then those events are re-delivered with their original ids and payloads.
  2. Given a replay is running, when I check its progress, then I see events replayed, remaining and failed, and I can cancel it.
  3. Given a replay would exceed a documented volume cap, when I request it, then I am told the cap and offered a narrower range or a fresh snapshot instead.
  4. Given replayed events arrive interleaved with live events, when my receiver processes them, then version-based ordering (DELV-10) still yields the correct final state.
  5. Given the requested range predates the event retention horizon, when I request it, then the call fails with the earliest available timestamp stated.
Implementation notes

Requires events to be persisted independently of delivery attempts; a time-series collection with the same shape as accessLogs (which already has a one-year TTL and hourly granularity) is the obvious storage model.

Edge cases & risks

Replay is a self-inflicted load amplifier — an unthrottled replay of a month of hotel.updated events can exceed the live rate by orders of magnitude, so it needs its own rate limit separate from live delivery.

DELV-17

Drop files into a customer-owned bucket

Proposed
Customer data engineerShould
As a customer data engineer, I want exports written straight into my own S3 or GCS bucket, so that the file lands where my pipeline already looks and never sits in Ubio's storage.
Acceptance criteria
  1. Given I configure a destination with a bucket, prefix and credentials (an assumed IAM role for S3, or a service account granted write access for GCS), when an export completes, then the object is written to my bucket at the configured prefix.
  2. Given the write succeeds, when I view the export row, then it shows the destination path instead of a Hotel Universe download button.
  3. Given my credentials are invalid or the write is denied, when the export completes, then the export is marked failed with the storage error surfaced, not the generic export failure message.
  4. Given my retention policy governs the bucket, when the file lands, then the 30-day platform lifecycle does not apply, because the object is mine.
  5. Given a scheduled export targets my bucket, when each run completes, then the object key includes the run timestamp so runs do not overwrite each other.
Implementation notes

GoogleCloudStorageService.uploadStream already terminates the pipeline; a customer destination is a second implementation of the same sink interface, with credentials stored per organisation. Cross-cloud writes to S3 add a dependency the platform does not have today.

Edge cases & risks

Holding customer write credentials makes Ubio a target and shifts failure modes into someone else's IAM policy; a misconfigured prefix can overwrite unrelated objects in the customer's bucket, so the destination should be validated with a test write before first use.

DELV-18

Deliver over SFTP for legacy partners

Proposed
OTA supply managerCould
As an OTA supply manager at a partner still running batch integrations, I want files pushed to our SFTP server on a schedule, so that I can consume Hotel Universe without building an HTTP client.
Acceptance criteria
  1. Given I configure host, port, path, username and an SSH key, when a scheduled export completes, then the file is uploaded to that path.
  2. Given the connection fails, when the retry policy runs, then it uses the same backoff as webhook delivery and surfaces the failure in the delivery log.
  3. Given the host key changes, when the connection is attempted, then it is refused and flagged rather than trusted on first use.
  4. Given a partner expects a specific naming convention, when the file is written, then the filename template is configurable with list name, type, format and timestamp tokens.
  5. Given the upload is interrupted, when the next attempt runs, then the file is written to a temporary name and renamed on completion so the partner never reads a partial file.
Implementation notes

Another sink behind the same export pipeline; egress to arbitrary customer hosts needs the same guardrails as webhook endpoints, and the existing PROXY_HOST/USERNAME/PASSWORD undici configuration shows the platform already reasons about outbound network paths.

Edge cases & risks

SFTP has no signature or idempotency concept, so the write-temp-then-rename discipline is the only protection against partial reads; long-lived SSH keys held per customer are a credential-management burden disproportionate to the number of partners likely to want this.

DELV-19

Publish a warehouse share

Proposed
Data analystCould
As a data analyst, I want Hotel Universe data available as a Snowflake, BigQuery or Databricks share, so that I can query canonical hotels next to my own bookings without operating a loader at all.
Acceptance criteria
  1. Given my organisation subscribes to a share, when it is provisioned, then tables for hotels, features, rooms, images and (where licensed) reviews appear in my account without any file transfer.
  2. Given the share is refreshed, when new data lands, then the refresh cadence is documented and the last-refreshed timestamp is queryable from within the share.
  3. Given my subscription covers only certain hotels or sources, when I query, then the share exposes only those rows, enforced at the provider level rather than by convention.
  4. Given my subscription lapses, when access is revoked, then the share becomes unavailable and this is reflected in the portal.
  5. Given the schema changes, when a new version is published, then the previous version remains queryable for a documented deprecation window.
Implementation notes

Depends on Parquet output (EXPT-12) as the physical format and on the snapshot/changelog contract (DELV-15) for refresh; row-level filtering by subscription reuses the same subscribedHotels set the paid-tier export rule already applies.

Edge cases & risks

Warehouse shares are cloud- and region-specific and the export bucket is europe-west1 today, so data residency has to be decided per customer; per-organisation row filtering across a share is a materially harder access-control problem than filtering a file at export time.

DELV-20

Stream events to high-volume partners

Proposed
OTA product managerWon't-yet
As an OTA product manager running a large supply integration, I want a durable stream rather than per-event HTTP callbacks, so that millions of daily changes do not depend on my endpoint keeping up.
Acceptance criteria
  1. Given my organisation is provisioned for streaming, when I connect with my credentials, then I can consume a partitioned topic of change events scoped to my subscription.
  2. Given I consume events, when I commit offsets, then I can resume from my last committed offset after a restart with no loss.
  3. Given events are partitioned, when they are produced, then partitioning is by huId so all changes for one hotel are ordered relative to each other.
  4. Given I fall behind, when the topic retention window is reached, then I am alerted before data ages out rather than discovering the gap afterwards.
  5. Given a partner is not provisioned for streaming, when they request it, then they are routed to webhooks or bucket delivery instead.
Implementation notes

Would be the fan-out tier behind the same internal event bus that feeds webhooks; the platform's current async model is PendingPromises plus in-process periodic tasks, so this is a genuine infrastructure addition rather than a refactor.

Edge cases & risks

Operating a multi-tenant streaming platform is a large ongoing cost that is hard to justify before a handful of partners actually need it, which is why this is deliberately staged behind webhooks and bucket delivery; tenant isolation on a shared cluster is the main security concern.

INTG 3 built5 partial14 proposed

Third-party integrations

Hotel Universe already runs on a handful of third parties — Google OAuth for identity, Stripe for billing, SendGrid for the single invitation email, Google Places for entity resolution, an external metasearch service, Google Cloud Storage for exports and images.automation.cloud for image hosting — but none of them are integrations a customer chooses. This epic covers those existing dependencies honestly, then proposes the connectors that would put Hotel Universe inside the systems its personas actually work in: PMS and channel managers on the supply side, RMS and BI tools on the analysis side, and Slack, spreadsheets, browsers and no-code tools where the day-to-day decisions get made.

INTG-01

Sign in with Google

Built
ProspectMust
As a prospect, I want to sign in with my existing Google account, so that I can get into the portal without creating another password.
Acceptance criteria
  1. Given I press "Continue with Google" or a landing CTA, when the OAuth flow completes, then the portal posts the code to POST /auth/google and receives {user, token}.
  2. Given this is my first sign-in, when the user is created, then any pending invitations for my email are processed, and if I belong to no organisation one is auto-created as "<name>'s Organization" at tier TRIAL.
  3. Given the exchange fails, when GoogleAuthCallback.vue handles the response, then the error state parses the Authentication failed: {json} payload rather than showing a blank screen.
  4. Given I want to sign in another way, when I look at the login screen, then there is no email/password, SAML or other SSO option — Google is the only identity provider.
  5. Given my token is nearing expiry, when the refresh interval fires, then POST /auth/refresh renews it every 5 minutes against ubio_last_token_refresh.
Implementation notes

AppLogin.vue → scaffold AuthService (authProvider: 'google', scope email profile) → redirect ${FRONTEND_URL}/googleAuthCallback; server side uses GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET and issues a JWT (JWT_TOKEN_EXPIRATION_SECONDS, e.g. 604800). app/public/silent-check-sso.html and AC_PUBLIC_ENV_KEYCLOAK_URL are legacy and unused.

Edge cases & risks

A single external identity provider makes Google a hard availability and eligibility dependency — a customer whose corporate identity is Microsoft or Okta simply cannot be onboarded; internal super-user status is derived from the email domain (@ub.io, plus ubio.ai in the portal), which ties privilege to a string comparison on a Google-supplied claim.

INTG-02

Take payment through Stripe

Partial
Ubio finance opsMust
As Ubio finance ops, I want subscriptions, invoices and card management handled by Stripe, so that we do not build billing infrastructure ourselves.
Acceptance criteria
  1. Given a customer selects a plan, when POST /billing/checkout runs, then a Stripe Checkout session in subscription mode is created with metadata organizationId and success/cancel URLs under ${FRONTEND_URL}/billing.
  2. Given a customer wants to change a card, when POST /billing/management runs, then a billingPortal.sessions.create URL is returned with return_url back to /billing.
  3. Given a customer views invoices, when GET /billing/invoices runs, then the last 10 invoices are returned with {id, date, description, status, amount, currency, invoiceUrl}.
  4. Given the plan chosen is pro, pro_plus or ultra, when access is derived, then all three currently grant identical access — the code TODO "Decide whether / how much to gate based on tier / subscription plan" is unresolved. This is the named gap.
  5. Given usage occurs, when the billing period closes, then no usage is metered to Stripe; quotas are enforced internally from accessLogs.
Implementation notes

Stripe apiVersion 2025-02-24.acacia, price IDs from STRIPE_PRICE_PRO|PRO_PLUS|ULTRA. The live portal goes considerably further with per-product plans (smartFeed, metasearch, richContent), POST /billing/change-tier|cancel|reactivate, overage lines and metasearch prepaid top-ups.

Edge cases & risks

Internal quota enforcement plus external subscription state means the two can disagree, and only the internal one blocks the customer; the org-by-customer lookup on webhook is a full collection scan, so billing correctness degrades exactly as the customer base grows.

INTG-03

Send transactional email through SendGrid

Partial
Organisation adminMust
As an organisation admin, I want my colleagues to receive a working invitation email, so that they can join the organisation without me forwarding a link by hand.
Acceptance criteria
  1. Given I invite someone via POST /invitations, when the email is sent, then the subject is "You've been invited to join ${organizationName} on Hotel Universe" and the CTA links to ${FRONTEND_URL}/invite/accept?token=${invitation.id}.
  2. Given the email is rendered, when the recipient reads the footer, then support@ubio.ai is shown as the contact.
  3. Given SendGrid throws while sending, when the error is handled, then the invitation record is deleted so the user is not left with an unsendable invitation.
  4. Given the invitation is not accepted, when 7 days pass, then it expires.
  5. Given any other lifecycle event occurs — welcome, export ready, quota warning, subscription expiring — then no email is sent, because sendInvitationEmail is the only template that exists. This is the named gap.
Implementation notes

SENDGRID_API_KEY and SENDER_EMAIL; the accept endpoint POST /invitations/{id}/accept is deliberately unauthenticated because the token arrives from the email.

Edge cases & risks

Deleting the invitation on a send failure conflates "email provider is down" with "invitation is invalid" and loses the admin's intent; with one template and no bounce or suppression handling, deliverability problems are invisible until a customer says nobody received anything.

INTG-04

Resolve hotels against Google Places

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want unresolved source records matched against Google Places, so that hotels arriving without usable reference ids still land on a canonical huId.
Acceptance criteria
  1. Given source documents in sourceHotelData5 with huId == null, when the link job streams them, then it first tries the refs ladder and only then falls back to findHuIdByGooglePlaceId.
  2. Given the fallback runs, when Places is queried, then it is POST places:searchText with X-Goog-FieldMask: places.id, locationBias.circle radius 1000 and includedType: lodging.
  3. Given candidates are returned, when they are scored, then confidence is nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3 and a match is accepted at ≥ 0.7.
  4. Given no candidate reaches the threshold, when the job finishes with that record, then huId stays null and the record remains visible as unmatched rather than being force-matched.
  5. Given the API key is missing or the quota is exhausted, when the call fails, then the record is left unresolved for a later run rather than being marked matched.
Implementation notes

packages/link with AddressMatcher; similarity is Jaccard plus Levenshtein, postcode compared exactly after normalisation, coordinates by Haversine. Key is GOOGLE_PLACES_API_KEY.

Edge cases & risks

The documented weakness is that "123 Main St" and "124 Main St" are not well distinguished, so a 0.7 acceptance can confidently merge two neighbouring properties; a hard dependency on Google Places for the fallback means matching quality and cost both move with someone else's API, and the sixth rung of the resolution ladder (findGooglePlace) is still a TODO.

INTG-05

Proxy live prices from the metasearch service

Partial
Revenue managerMust
As a revenue manager, I want live OTA offers for a property and date range, so that I can see who is selling my hotel and at what price right now.
Acceptance criteria
  1. Given I have metasearch access, when I call GET /metasearch with hotelIdType, hotelId, checkIn, nights, adults, children, rateType, currency, locale and deviceType, then the request is proxied to METASEARCH_SERVICE_URL and ProviderOffer[] is returned.
  2. Given my organisation is not on the FULL tier (or lacks a metasearch subscription on the live system), when I call the endpoint, then I receive 402 and the portal routes me to Billing via requiresMetasearchAccess.
  3. Given I exceed RATE_LIMIT_REQUESTS (default 100) within RATE_LIMIT_DURATION_SECONDS (default 60), when the next call is made, then I receive 429 with Retry-After.
  4. Given a lookup succeeds, when it is recorded, then an accessLogs row with action metasearch:view is written.
  5. Given children are included in the guest configuration, when the query is built, then their age is fixed at 10 and location-based search is unsupported — documented limitations of the upstream service.
Implementation notes

METASEARCH_SERVICE_URL / METASEARCH_SERVICE_TOKEN; GET /metasearch is the only endpoint in the API with rate limiting applied. Metasearch is restricted to huId and GoogleHotels id types (METASEARCH_HOTEL_ID_TYPES in the portal).

Edge cases & risks

A documented 2–5 s response makes any bulk or fan-out use (comp-set matrices, price alerting) latency-bound on a third party we do not control; the prepaid allowance model on the live system means an exhausted balance surfaces as a 402 with reason metasearch_topup_required, which is easy to confuse with a tier problem.

INTG-06

Store export artefacts in Google Cloud Storage

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want a verified storage dependency for export artefacts, so that a misconfigured bucket is caught at deploy time rather than by a customer's failed export.
Acceptance criteria
  1. Given the service starts, when GCS is configured, then GCS_PROJECT_ID, GCS_BUCKET_NAME and either GCS_KEY_FILE or GCS_SERVICE_ACCOUNT_KEY are present.
  2. Given I run npm run verify:gcs, when the check executes, then upload, signed-URL generation, size lookup and delete are all exercised against the configured bucket.
  3. Given the bucket is hotel-universe-exports-dev in europe-west1, when objects are written, then soft delete retains them 7 days and the lifecycle rule deletes them at 30 days.
  4. Given the service account lacks signing permission, when a download is requested, then the failure is surfaced as an export error rather than an empty downloadUrl.
  5. Given a customer requires data residency outside europe-west1, when they ask, then there is no per-organisation bucket or region option today.
Implementation notes

GoogleCloudStorageService exposes uploadStream, getSignedDownloadUrl (v4), deleteFile and getFileSize; it is the only storage sink in the platform.

Edge cases & risks

A single shared bucket for all tenants means object-key discipline ({listId}/{exportId}/…) is the only isolation between organisations; region is fixed, so an EU-only or US-only commitment cannot currently be honoured per customer.

INTG-07

Serve hotel images through the image host

Partial
OTA product managerShould
As an OTA product manager, I want stable, resizable image URLs for every hotel, so that I can render galleries without hot-linking to OTA CDNs.
Acceptance criteria
  1. Given images are ingested, when encodeImageUrls runs, then each URL is rewritten to https://images.automation.cloud/img?hash=<AES-256-CBC ciphertext>.<iv>.
  2. Given I request a resize, when I call GET /img/resize?hash&w&h, then the image is cover-resized with sharp using cubic interpolation and returned as JPEG quality 75.
  3. Given an image arrives through the merge path, when it is stored, then width, height and caption are dropped, whereas ImageUpdateService preserves them — a known inconsistency between the two write paths.
  4. Given anyone calls /img/resize without a token, when the request is served, then it succeeds, because the image app has no authentication.
  5. Given the same image is supplied by several sources, when it is stored, then it is not deduplicated — ImageUpdateService carries an explicit TODO for this.
Implementation notes

The AES key used for URL encoding is hard-coded in the ingest path; ImageUpdateService buffers 100 images or 5 seconds; the resize app's cache-control header is commented out. The portal does its own client-side dedupe in utils/imageDedupe.ts and handles broken images in HotelImages.vue.

Edge cases & risks

An unauthenticated resize endpoint with no cache-control is both an open compute cost and an easy amplification target; a hard-coded encryption key means every historical image URL is invalidated if it ever has to be rotated, and losing width/height on the merge path forces the portal to lay out galleries without knowing aspect ratios.

INTG-08

Call external NodeScript services for parsing

Partial
Ubio data ops engineerShould
As a Ubio data ops engineer, I want address normalisation and difficult page parsing handled by dedicated services, so that the ingest pipeline is not carrying that logic inline.
Acceptance criteria
  1. Given a raw address string, when AddressSplittingService runs, then it posts {rawAddress} to NS_ADDRESS_SPLITTING_ENDPOINT and receives a structured HotelLocation.
  2. Given the Google Travel scraper needs assistance, when it calls its NodeScript endpoint, then it retries up to 5 times with a 30-second timeout.
  3. Given that endpoint requires no authentication, when it is called, then the request succeeds — the lack of auth is a known gap.
  4. Given the service is unavailable, when the call fails after retries, then the record keeps its rawAddress and the scraping status is set to error rather than storing a half-parsed address.
  5. Given a proxy is configured, when remote calls are made, then PROXY_HOST/PROXY_USERNAME/PROXY_PASSWORD are used through an undici ProxyAgent.
Implementation notes

Used from packages/scrapers; the same scraper classes are also registered in the API app to power the unauthenticated /scrapers/* endpoints.

Edge cases & risks

rejectUnauthorized: false on the proxy agent disables certificate verification for all remote calls, which is a meaningful exposure for a path that carries scraped content into canonical data; an unauthenticated helper endpoint is a free parsing service for anyone who discovers it.

INTG-09

Connect a hotel's PMS

Proposed
Hotel ownerShould
As a hotel owner, I want to connect my property management system, so that my own rates and availability sit alongside the OTA prices instead of me typing them in.
Acceptance criteria
  1. Given I open an integrations screen, when I choose my PMS (for example Opera, Mews or Cloudbeds) and authorise it, then the connection is stored against my organisation and the properties it exposes are listed.
  2. Given a PMS property is listed, when I map it to a huId, then the mapping is saved and shown on the hotel's detail page alongside the existing HotelReferences ids.
  3. Given the connection is active, when rates and availability sync, then my own rate appears in the price comparison views as a first-party source, clearly distinguished from scraped direct rates.
  4. Given authorisation is revoked at the PMS, when the next sync runs, then the connection is marked disconnected and I am prompted to reauthorise instead of the data silently going stale.
  5. Given a property cannot be mapped confidently, when the mapping step runs, then it is routed to a review queue rather than guessed.
Implementation notes

Mapping reuses the existing manual-linking pattern already built for Direct ARI (ManualIbeMatching.vue, POST /private/direct-pricing/link-manual), and the connected rate can flow into the same views as /direct-pricing/prices/{huId}.

Edge cases & risks

Every PMS has a different rate model (rate plans, LOS rules, derived rates), so a naive "nightly rate" mapping will misrepresent restricted rates; first-party rates are commercially sensitive in a way scraped data is not, which raises the bar on isolation and on who inside the organisation can see them.

INTG-10

Connect a CRS or channel manager

Proposed
Hotel ownerCould
As a hotel owner, I want my channel manager connected, so that I can see which OTAs I actually distribute to and reconcile that against where my rates are appearing.
Acceptance criteria
  1. Given I connect a channel manager, when the connection is established, then the channels I distribute to are listed per property.
  2. Given metasearch shows an advertiser selling my hotel, when it is compared against my distribution list, then unauthorised or unexpected sellers are flagged.
  3. Given a rate parity breach is detected against a channel I do distribute to, when it is surfaced, then it names the channel, the stay date and the gap.
  4. Given the channel manager rejects our credentials, when the sync runs, then the failure is surfaced in the integrations screen with a reauthorise action.
  5. Given a property exists in the channel manager but not in Hotel Universe, when the sync runs, then it is offered for matching rather than dropped.
Implementation notes

Builds directly on the metasearch advertiser data already returned as ProviderOffer[]; the "who is selling me" comparison is the same shape as the existing undercut analysis in HotelPriceInsights.vue.

Edge cases & risks

Advertiser names from metasearch are not clean channel identifiers, so matching them to channel-manager channel codes is its own fuzzy-matching problem with the same failure modes as hotel entity resolution.

INTG-11

Push comp-set signals into an RMS

Proposed
Revenue managerShould
As a revenue manager, I want Hotel Universe comp-set pricing delivered into my revenue management system, so that my pricing decisions are made where I already make them.
Acceptance criteria
  1. Given I connect an RMS (for example IDeaS or Duetto), when the integration is configured, then I choose which list or comp set feeds it and at what cadence.
  2. Given the feed runs, when it delivers, then it carries per-date competitor rates, the market median and my position versus median in the RMS's expected schema.
  3. Given my comp set is defined by criteria rather than a fixed list, when the feed runs, then it uses the same weighting model as CompetitorCriteria.vue (stars, guest rating, property type, amenities, distance) so the portal and the RMS agree.
  4. Given a competitor has no price for a date, when the feed is built, then the gap is represented explicitly rather than as a zero.
  5. Given my metasearch allowance is exhausted, when the feed runs, then it is skipped with a recorded reason and I am warned, rather than delivering a partial comp set that looks complete.
Implementation notes

The data already exists behind GET /hotels/{huId}/competitor-price-matrix and GET /list-price-matrix; this is a scheduled delivery of that payload in a partner schema, riding on the delivery infrastructure proposed in DELV.

Edge cases & risks

RMS systems act on what they are fed, so a silently truncated or stale comp set changes real prices — provenance and completeness metadata matter more here than in any read-only surface; comp sets computed by criteria can change membership between runs, which an RMS will read as genuine market movement.

INTG-12

Connect Google Hotel Center

Proposed
Hotel ownerCould
As a hotel owner, I want my Google Hotel Center account connected, so that the property I manage in Google is definitively the same property Hotel Universe is tracking.
Acceptance criteria
  1. Given I authorise Hotel Center, when the connection completes, then my properties are listed with their Google identifiers.
  2. Given a property is connected, when it is matched, then the googleHotelId is written into refs and the deterministic huId follows from it without any fuzzy matching.
  3. Given Google reports my direct rate as advertised, when it is shown, then it is compared against the direct rate we scrape from the booking engine, and disagreements are flagged.
  4. Given I disconnect the account, when the next sync runs, then previously imported identifiers are retained but no longer refreshed, and this state is visible.
  5. Given a property in Hotel Center has no corresponding canonical record, when the sync runs, then it is queued for ingest rather than silently ignored.
Implementation notes

This is the strongest possible signal for entity resolution, because HuId is generated as sha1('31b54e6e4b#' + dsHotelId) for GoogleHotels — an authoritative googleHotelId bypasses the confidence-scored ladder entirely.

Edge cases & risks

Hotel Center access is chain- and agency-mediated in practice, so the authorising user often does not own every property they can see; a wrong authoritative id is worse than no id, because it is trusted above the scoring model.

INTG-13

Connect OTA extranets

Proposed
Hotel ownerWon't-yet
As a hotel owner, I want to connect my Booking.com and Expedia extranet accounts, so that my own listing data comes from the source rather than from scraping.
Acceptance criteria
  1. Given I authorise an OTA partner account, when the connection completes, then my properties there are listed with their native ids (bookingHotelId, expediaHotelId).
  2. Given a property is connected, when the ids are written into refs, then subsequent merges treat them as authoritative, ahead of scraped references.
  3. Given official partner data is available for a field, when the canonical record is recomputed, then it takes priority over the scraped value for that field and the provenance in _dsNames reflects it.
  4. Given the partner API imposes rate limits, when syncing, then they are respected per account and surfaced as a sync cadence rather than as errors.
  5. Given no partner programme access is available for an OTA, when a customer asks for it, then the limitation is stated plainly instead of implying an official connection exists.
Implementation notes

Would displace parts of packages/scrapers for connected properties — the Booking mobile-app JSON endpoints and TripAdvisor persisted GraphQL queries currently run with spoofed clients, proxies and fixed device identifiers.

Edge cases & risks

Official partner programmes have eligibility and contractual requirements that Ubio may not meet, which is why this is staged as Won't-yet; running official and scraped paths side by side also means the merge priority order in DS_NAMES has to gain a notion of "authoritative for this property" rather than "authoritative globally".

INTG-14

Connect BI tools over a semantic layer

Proposed
Data analystShould
As a data analyst, I want to point Looker, Tableau or Power BI at Hotel Universe, so that I can build my own dashboards instead of asking for another export.
Acceptance criteria
  1. Given I configure a connection, when it authenticates, then it uses a scoped PAT (pat_…) rather than my personal session token.
  2. Given the connection is live, when I browse the model, then I see governed entities — hotels, features, rooms, ratings, prices, list membership — with documented dimensions and measures rather than raw collections.
  3. Given my organisation's tier restricts a measure (for example metasearch prices), when I query it, then the query is refused with an explanatory error rather than returning nulls.
  4. Given a query would scan an unreasonable volume, when it is planned, then it is rejected with guidance to filter by list or region.
  5. Given I share a dashboard with a colleague in the same organisation, when they open it, then their own access is evaluated, not the dashboard creator's.
Implementation notes

PATs already exist with per-token scopes (exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view) and never expire; the semantic layer would most naturally sit over the warehouse share proposed in DELV-19 rather than over MongoDB directly.

Edge cases & risks

Non-expiring PATs embedded in a BI tool are a long-lived credential in a system with no per-user roles, so any token leak is an organisation-wide leak; BI users generate unpredictable query shapes, which is exactly what Atlas Search's fixed index (atlas_custom_search_10, dynamic: false) is not built for.

INTG-15

Publish a Zapier and Make app

Proposed
Travel agency consultantCould
As a travel agency consultant, I want Hotel Universe available in Zapier and Make, so that I can wire a list into our workflow tools without asking a developer.
Acceptance criteria
  1. Given I install the app, when I authenticate, then I connect with a scoped PAT and the app shows which organisation it is acting for.
  2. Given I build a Zap, when I choose a trigger, then options include "new hotel added to list", "export ready" and "rows need review", backed by the outbound events proposed in DELV.
  3. Given I choose an action, when I configure it, then I can create a list, append hotels to a list, start an export, or run a search and return matches.
  4. Given a run fails, when it is reported, then the underlying API error message is passed through rather than a generic failure.
  5. Given a trigger's underlying event type is not available on my tier, when I select it, then the app explains what to upgrade rather than failing at run time.
Implementation notes

Depends on webhook triggers existing at all (DELV-04 onward); polling triggers over GET /lists and GET /lists/{listId}/exports would be the fallback for a first release.

Edge cases & risks

No-code users will happily build loops that create a list per row, so per-organisation rate limiting must exist before this ships — today only GET /metasearch is rate-limited; a leaked Zapier connection is an unexpiring PAT in a third-party account.

INTG-16

Post alerts to Slack

Proposed
Revenue managerShould
As a revenue manager, I want price and comp-set alerts in our Slack channel, so that the team sees an undercut without anyone logging into the portal.
Acceptance criteria
  1. Given I install the Slack app and choose a channel, when the connection is authorised, then it is stored against my organisation and a test message is posted.
  2. Given a price threshold is crossed, when the alert is posted, then it names the hotel, stay date, advertiser, both prices and the gap, and links to the competitor price matrix.
  3. Given an export becomes ready, when the message is posted, then it links to the exports drawer rather than embedding a signed URL.
  4. Given the same condition persists, when subsequent snapshots are evaluated, then a cooldown prevents repeat posts until it clears or worsens materially.
  5. Given the Slack token is revoked, when a post fails, then the integration is marked disconnected and an admin is prompted to reconnect.
Implementation notes

Rides on the event catalogue and delivery machinery in DELV (price threshold, export ready, review needed); the alert content mirrors what CompetitorPriceMatrix.vue and HotelPriceInsights.vue already compute.

Edge cases & risks

Alert fatigue is the failure mode — a 500-hotel comp set across a long lookahead can generate thousands of crossings a day, so daily digests should probably be the default and per-event alerts the exception.

INTG-17

Post alerts to Microsoft Teams

Proposed
Corporate travel buyerCould
As a corporate travel buyer, I want the same alerts in Microsoft Teams, so that our programme reviews happen where our organisation actually communicates.
Acceptance criteria
  1. Given I add the Teams connector, when I authorise it against a channel, then a test card is posted and the connection is saved.
  2. Given an alert fires, when the card is rendered, then it uses an adaptive card with the same fields as the Slack message and an action button back into the portal.
  3. Given my organisation restricts external connectors, when installation is blocked, then the portal explains the tenant policy problem rather than reporting a generic failure.
  4. Given both Slack and Teams are configured, when an alert fires, then it is delivered to both without duplication within either.
  5. Given the connector is removed in Teams, when the next post fails, then the integration is marked disconnected in the portal.
Implementation notes

Same event source and cooldown logic as INTG-16, differing only in the message renderer and the authorisation flow.

Edge cases & risks

Teams tenant policies frequently block third-party connectors outright, so a meaningful share of enterprise buyers will not be able to install it regardless of what we build; maintaining two renderers doubles the surface for formatting regressions.

INTG-18

Enrich CRM records with hotel data

Proposed
Ubio sales repCould
As a Ubio sales rep, I want hotel accounts in Salesforce or HubSpot enriched with canonical Hotel Universe data, so that I can prioritise outreach without switching tools.
Acceptance criteria
  1. Given a CRM account has a hotel name and address, when enrichment runs, then it is matched to a huId using the same confidence-scored matcher as CSV upload, and the score is written back.
  2. Given a match is confident, when the record is enriched, then canonical fields — property type, star rating, review count, average rating, whether direct ARI is available — are written to the CRM.
  3. Given a match is uncertain, when enrichment finishes, then the account is flagged for review rather than being enriched with a guess.
  4. Given the CRM record is updated later, when enrichment re-runs, then it only overwrites fields it owns and never clobbers sales-entered values.
  5. Given enrichment is disabled, when it is switched off, then previously written fields remain but stop updating.
Implementation notes

Reuses POST /lists/upload details-mode matching (name plus one of street address, city, postal code or country, plus coordinates) and the review states confirmed | needs_review | unmatched.

Edge cases & risks

CRM data is far messier than a supply file — chain-level accounts, head-office addresses and duplicate records — so match rates will be materially lower than for OTA supply files; writing back scores that sales staff then treat as facts is its own risk.

INTG-19

Overlay Hotel Universe data on OTA pages

Proposed
OTA supply managerCould
As an OTA supply manager, I want a browser extension that shows canonical data and comparison prices while I am on an OTA listing, so that I can check a property without leaving the page.
Acceptance criteria
  1. Given I install the extension and sign in, when I open a Booking, Expedia, TripAdvisor or Google Hotels property page, then the extension resolves the page's native id to a huId and shows a panel.
  2. Given the hotel resolves, when the panel renders, then it shows canonical name and address, star rating, review count, data-availability indicators and a link to the full profile.
  3. Given I have metasearch access, when I open the panel, then it can fetch comparison offers for the dates on the page.
  4. Given the hotel cannot be resolved, when the panel renders, then it offers to submit the page for matching rather than showing an empty box.
  5. Given I am signed out or my token has expired, when I open the panel, then it prompts me to sign in and does not attempt unauthenticated calls.
Implementation notes

Resolution is a direct use of GET /search with hotelIdType and hotelId (the search builder short-circuits to $match {_id} for huId and uses equals on refs.<type> otherwise); the panel is essentially HotelPreview plus the metasearch call.

Edge cases & risks

OTA page structures change constantly, so id extraction is brittle in exactly the way the scrapers already are; an extension holding a long-lived token in browser storage is a broader attack surface than the portal's ubio_auth_token, and OTAs may object to overlays on their pages.

INTG-20

Provide spreadsheet add-ins

Proposed
Corporate travel buyerShould
As a corporate travel buyer, I want a Google Sheets and Excel add-in, so that I can build a hotel programme in the tool I already negotiate in.
Acceptance criteria
  1. Given I install the add-in and authenticate with a scoped PAT, when it connects, then it shows which organisation and tier I am using.
  2. Given I have a sheet of hotel names and addresses, when I run "Match hotels", then rows are matched through the same pipeline as CSV upload and each row gets a huId, a confidence score and a state.
  3. Given rows are matched, when I run "Enrich", then selected canonical fields are written into new columns without disturbing my existing ones.
  4. Given I have a list in the portal, when I run "Import list", then its hotels are pulled into the sheet with the same columns as a preview export.
  5. Given I exceed my tier's limits mid-run, when the run stops, then already-written rows are kept and the reason is shown in a status cell rather than the whole run failing silently.
Implementation notes

The add-in is a thin client over the existing REST API — POST /lists/upload or POST /lists/fromIds, GET /lists/{listId}/entries, GET /hotels/{huId} — using the same PAT scopes as any other integration.

Edge cases & risks

Spreadsheets encourage row-by-row API calls, which will hit rate limits and quotas that were sized for a portal, not a fill-down; the trial cap of 100 distinct hotels viewed all-time is easy to exhaust accidentally from a sheet.

INTG-21

Sign in to partner portals with Hotel Universe

Proposed
AI agent builderCould
As an AI agent builder embedding Hotel Universe in my own product, I want my users to reach Hotel Universe views without a second login, so that the integration feels like one product.
Acceptance criteria
  1. Given my partner application is registered, when a user is sent to Hotel Universe through an OIDC flow, then they land authenticated in the correct organisation.
  2. Given the user has no Hotel Universe account, when they arrive, then one is provisioned into the partner's organisation instead of the default "<name>'s Organization" at tier TRIAL.
  3. Given the partner revokes a user, when they next arrive, then access is refused and their session is terminated.
  4. Given a partner wants embedded views, when they request one, then a scoped, short-lived token is issued for that view only rather than a full portal session.
  5. Given the flow fails, when the error is shown, then it distinguishes "not provisioned" from "authentication failed".
Implementation notes

The platform is currently Google-OAuth-only and issues its own JWTs (JWT_ISSUER, JWT_PRIVATE_KEY, JWT_PUBLIC_KEY), so it already has the signing infrastructure to be an identity relying party or provider; POST /auth/switch-org shows organisation context is already swappable inside a session.

Edge cases & risks

Auto-provisioning into someone else's organisation is a privilege-granting action, and with no per-user roles every provisioned user gets the same full scope set as the admin who invited them; internal super-user status keyed on the @ub.io email domain must not be reachable through a partner-supplied claim.

INTG-22

Offer a partner and reseller API

Proposed
Ubio sales repShould
As a Ubio sales rep, I want resellers to provision and monitor their own sub-accounts, so that we can grow through partners without Ubio operating every customer's onboarding.
Acceptance criteria
  1. Given a reseller has a partner credential, when they call the provisioning API, then they can create a sub-organisation, set its tier and issue scoped PATs for it.
  2. Given sub-accounts exist, when the reseller queries usage, then they receive a roll-up per sub-account by action (hotels:view, reviews:view, metasearch:view, lists:create, export:create:, export:download:) for a date range.
  3. Given a sub-account exceeds its allocation, when the limit is reached, then the reseller is notified and can raise it without a Ubio ticket.
  4. Given a reseller tries to read a sub-account's list contents or hotel data, when the request is made, then it is refused — provisioning and usage visibility do not imply data access.
  5. Given a sub-account is suspended, when its users authenticate, then they are refused with a message pointing at their reseller rather than at Ubio support.
Implementation notes

GET /stats/ already aggregates distinct hotels per action per organisation and is the natural roll-up source; organisation creation, membership and PAT issuance all exist as endpoints today. The isUbee() super-user check (email contains @ub.io) is the only multi-organisation visibility mechanism at present, which a partner tier cannot reuse.

Edge cases & risks

Reseller provisioning creates a genuine privilege hierarchy in a system whose entire access model is "member of an organisation, all scopes equal" — that gap has to be closed first, or a partner credential becomes an undifferentiated super-user; usage roll-ups also expose one customer's activity volume to a third party, which needs to be contractually explicit.

Part 7

Rates & price intelligence

META 12 built3 partial11 proposed

Metasearch: OTA price comparison

Metasearch is the product that turns a canonical hotel record into a live commercial answer: for one property, one set of dates and one guest configuration, GET /metasearch proxies to METASEARCH_SERVICE_URL and returns ProviderOffer[] — who is advertising the hotel, at what total and per-night price, refundable or not, and with which rooms and rates behind the headline. The portal wraps it as Metasearch.vue with MetasearchFilter.vue and MetasearchResults.vue, mirrors every parameter into the URL, and gates the whole surface behind hasMetasearchAccess() and a per-principal rate limit. The built stories here describe a deliberately narrow single-hotel, single-date query; the proposed stories widen it into a rate-shopping tool — batches, calendars, normalisation across advertisers, parity detection and history.

META-01

Compare live OTA offers for one property and stay

Built
Revenue managerMust
As a Revenue manager, I want to pull every OTA offer for one of my hotels on a given stay, so that I can see who is selling my rooms and at what price without opening a browser tab per advertiser.
Acceptance criteria
  1. Given a bearer token carrying the metasearch:view scope on a FULL-tier organisation, when I call GET /metasearch with hotelIdType, hotelId, checkIn, nights, adults, children, rateType, currency, locale and deviceType, then I receive a ProviderOffer[] describing the advertisers currently selling that property for that stay.
  2. Given the request is accepted, when the API handles it, then it proxies the query to METASEARCH_SERVICE_URL and writes one metasearch:view row into the accessLogs time-series collection against the resolved huId.
  3. Given the upstream metasearch service is unavailable or times out, when I call the endpoint, then I receive an error response rather than an empty 200 that reads as "this hotel has no offers".
  4. Given I omit hotelId, when I call the endpoint, then the request is rejected by schema validation before any upstream call is made, so a malformed query never consumes rate-limit budget upstream.
Implementation notes

GET /metasearch in the api package, documented as a 2–5 second call because it fans out to live advertiser sources; the portal calls it through ApiManager.getMetasearch({hotelIdType, hotelId, checkIn, nights, adults, children, rateType, currency, locale, deviceType}) and renders through views/Metasearch.vue. Integration tests for metasearch exist in the API clone.

Edge cases & risks

The endpoint has no location search — you must already know the hotel — so a customer without a resolved identifier cannot use it at all; and because the response is a live proxy, two identical calls seconds apart can legitimately disagree, which reads as a bug to anyone who has not been told the data is live.

META-02

Restrict the hotel identifier to Hotel Universe and Google Hotels IDs

Built
Customer data engineerMust
As a Customer data engineer, I want the metasearch query to accept only the identifier types it can actually resolve, so that I get an immediate validation error instead of an empty result set when I pass a Booking or Expedia ID.
Acceptance criteria
  1. Given the metasearch filter panel, when I open the Hotel ID type selector, then only huId and Google Hotels are offered, per METASEARCH_HOTEL_ID_TYPES in types/hotel.ts.
  2. Given I call the API directly with hotelIdType=bookingHotelId, when the request is validated, then it is rejected with a 400 naming the allowed values huId|googleHotelId.
  3. Given I paste an identifier of the wrong shape for the selected type, when I run the search, then the error is surfaced in the results panel as a Message rather than as a silent empty state.
  4. Given search results elsewhere in the portal offer a "Check prices" button, when I use it, then the metasearch screen is prefilled with hotelIdType=huId and that hotel's huId so I never type an identifier by hand.
Implementation notes

The restricted list lives in the portal as METASEARCH_HOTEL_ID_TYPES (a subset of the full HOTEL_ID_TYPES / DS_NAMES set) and is mirrored by the hotelIdType enum on GET /metasearch. The prefill path is the Name-column "Check prices" action in HotelResultsTable.vue.

Edge cases & risks

The wider platform advertises matching on Booking, Expedia, TripAdvisor, Trivago and Agoda identifiers, so a customer who successfully built a list from Expedia IDs will reasonably expect to price it and cannot — the gap between list-building identifiers and pricing identifiers is a repeated support theme.

META-03

Choose check-in and check-out with nights computed automatically

Built
Travel agency consultantMust
As a Travel agency consultant, I want to pick the dates my client is travelling rather than compute a night count, so that I can quote a stay the way the client described it.
Acceptance criteria
  1. Given the metasearch filter panel, when I open the Check-in picker, then dates before today are not selectable.
  2. Given I pick a check-in and a check-out, when the selection completes, then nights is computed from the difference and sent to GET /metasearch as an integer.
  3. Given I pick a check-out on or before the check-in, when I try to search, then the search is blocked with a validation message rather than sending nights: 0 upstream.
  4. Given I change the check-in after a check-out is already set, when the new check-in is later than the check-out, then the check-out is cleared or advanced rather than left in an impossible state.
  5. Given a deep link supplies checkIn and nights, when the page loads, then the pickers are populated consistently so the displayed check-out matches the night count that will be queried.
Implementation notes

components/metasearch/MetasearchFilter.vue — Check-in / Check-out PrimeVue date pickers with min today and an auto-computed nights value; the API contract is checkIn (date) plus nights, not a date pair.

Edge cases & risks

Because the API takes checkIn + nights while the UI takes two dates, any timezone drift in the browser's date handling shifts the stay by a day; long stays are also unbounded in the schema, so a 300-night query is accepted and will be slow or empty upstream.

META-04

Set occupancy with adults and children, at a fixed child age

Partial
Corporate travel buyerShould
As a Corporate travel buyer, I want to price the actual party travelling, so that the rate I compare is the rate that party would pay.
Acceptance criteria
  1. Given the filter panel, when I set occupancy, then Adults accepts 1–10 and Children accepts 0–10, and both are sent as integers on GET /metasearch.
  2. Given I set Children above zero, when the query runs, then every child is priced at the fixed age of 10 because the upstream service takes no per-child ages.
  3. Given children are included, when the results render, then the fixed-age assumption is stated on screen so a consultant does not quote a family rate that will not reproduce at booking.
  4. Given I set adults to 0 via the API, when the request is validated, then it is rejected rather than passed upstream as an unbookable occupancy.
  5. Given I change the children count, when the search runs, then a metasearch_filter:children telemetry event is emitted.
Implementation notes

adults / children parameters on GET /metasearch; portal inputs in MetasearchFilter.vue with tracking events metasearch_filter:children through PortalTrackingManager.

Edge cases & risks

Child-occupancy rules differ sharply by advertiser and by property — free-under-12, extra-bed charges, adults-only properties — so a fixed age of 10 quietly produces the wrong total for a large share of family stays; the platform also carries an Adult-only feature tag it does not cross-check against the occupancy requested.

META-05

Tune the query with rate type and device type in Advanced Options

Built
OTA product managerShould
As an OTA product manager, I want to ask for refundable rates and to see what a mobile visitor is shown, so that I can compare like with like against our own storefront.
Acceptance criteria
  1. Given the filter panel, when I expand Advanced Options, then Rate Type offers cheapest and refundable and Device Type offers desktop and mobile.
  2. Given I select refundable, when the search runs, then rateType=refundable is sent and the returned offers are the refundable rates rather than the outright cheapest.
  3. Given I select mobile, when the search runs, then deviceType=mobile is sent, so mobile-only or app-only promotional rates appear where advertisers publish them.
  4. Given I expand or collapse Advanced Options, when I return to the screen later in the session, then the panel's open state is restored from loadPanelsState('metasearch-filter-panels').
  5. Given an unsupported rateType value is sent directly to the API, when it is validated, then a 400 is returned naming the allowed values.
Implementation notes

rateType and deviceType enums on GET /metasearch; the collapsible Advanced Options block in MetasearchFilter.vue persists via loadPanelsState('metasearch-filter-panels') and emits metasearch_filter:rate_type and metasearch_filter:device_type telemetry.

Edge cases & risks

Device type is a request hint to the upstream service, not a guarantee that mobile-exclusive inventory is returned, so an absent mobile discount is not evidence that none exists; cheapest and refundable also cannot be requested in the same call, which forces two rate-limited requests to answer the common question "how much does refundability cost here?".

META-06

Price in any ISO 4217 currency and any ISO 3166 locale

Partial
OTA supply managerShould
As an OTA supply manager, I want to see offers in the currency and market my customers actually book in, so that the comparison reflects the storefront they will see.
Acceptance criteria
  1. Given the Advanced Options panel, when I open the Currency selector, then the full ISO 4217 list is offered from utils/currencies.ts (sourced from the currency-codes package) with GBP preselected.
  2. Given the Advanced Options panel, when I open the Locale selector, then lowercase ISO 3166 alpha-2 country codes are offered from utils/locales.ts / utils/countries.ts with gb preselected.
  3. Given I change currency or locale, when the search runs, then the parameters are sent on GET /metasearch, the returned prices are formatted in the selected currency, and metasearch_filter:currency / metasearch_filter:locale telemetry events are emitted.
  4. Given I omit locale when calling the API directly, when the request is handled, then the server's own default applies — which is us in the API code even though docs/docs.http documents gb.
  5. Given an unrecognised currency code is sent directly, when the request is validated, then it is rejected rather than silently falling back to GBP.
Implementation notes

currency (default GBP) and locale (default us in code) on GET /metasearch; portal defaults and option lists in MetasearchFilter.vue backed by utils/currencies.ts and utils/locales.ts.

Edge cases & risks

A currency change does not necessarily mean the advertiser quoted natively in that currency — see META-22 — so a converted price can differ from the checkout price; and offering every ISO 4217 code including currencies no advertiser trades in invites empty result sets that look like coverage failures.

META-07

Render one offer per advertiser with tags and an outbound link

Built
Revenue managerMust
As a Revenue manager, I want each offer attributed to a named advertiser with a link I can follow, so that I can verify a price on the source site before I act on it.
Acceptance criteria
  1. Given results are returned, when MetasearchResults.vue renders, then each offer appears in its own fieldset headed by the advertiser name.
  2. Given an offer comes from the hotel's own channel or is a paid placement, when the offer renders, then a Hotel or Sponsored tag is shown next to the advertiser name.
  3. Given an offer carries a target URL, when I click "View on X", then the advertiser's page opens in a new tab as an external link, with X replaced by the advertiser's name.
  4. Given an offer has no usable link, when it renders, then the "View on X" affordance is omitted rather than rendered as a dead link.
  5. Given the same advertiser returns more than one offer, when results render, then each is a distinct fieldset rather than being collapsed into one.
Implementation notes

components/metasearch/MetasearchResults.vue — one fieldset per ProviderOffer, with Hotel / Sponsored PrimeVue tags and an external "View on X" link.

Edge cases & risks

Sponsored placement means position in the response is a commercial signal, not a price ranking, so reading the list top-down as "cheapest first" is wrong; advertiser naming also arrives from upstream unnormalised, so the same brand can appear under two spellings across queries.

META-08

Show total price, price per night and the free-cancellation date

Built
Travel agency consultantMust
As a Travel agency consultant, I want each offer to state both the stay total and the nightly rate plus the cancellation deadline, so that I can compare offers whose length of stay and flexibility differ.
Acceptance criteria
  1. Given an offer, when it renders, then both the total price for the stay and the price per night are shown in the selected currency.
  2. Given an offer is free to cancel until a date, when it renders, then that free-cancellation date is displayed on the offer header.
  3. Given an offer carries no cancellation information, when it renders, then the cancellation line is omitted rather than shown as "free" by default.
  4. Given nights > 1, when the per-night figure renders, then it is the total divided across the stay and is labelled as such, so nobody mistakes it for a rack rate.
  5. Given a price is missing or zero from upstream, when the offer renders, then the offer is shown with the price suppressed rather than rendered as "0".
Implementation notes

MetasearchResults.vue offer header — total price plus per-night price plus free-cancellation date; currency formatting flows from the currency parameter and utils/formatters.ts.

Edge cases & risks

Advertisers differ on whether the displayed total includes taxes, resort fees and city levies, so a per-night comparison across advertisers can be systematically misleading — the normalisation story META-23 exists precisely for this.

META-09

Expand an offer into its available rooms and rate plans

Built
OTA product managerShould
As an OTA product manager, I want to open an advertiser's offer and see which rooms and rate plans sit behind the headline price, so that I can tell whether a competitor is cheaper on the same room or on a smaller one.
Acceptance criteria
  1. Given an offer with room-level detail, when I expand the collapsible Available Rooms section, then each room is listed by name.
  2. Given a room has images, when it renders, then up to three preview images are shown and the remainder are not rendered.
  3. Given a room has rate plans, when the rates table renders, then each row shows the total and per-night price, a Refundable tag where applicable, and a "Cancel by" date where the advertiser supplies one.
  4. Given an offer has no room-level breakdown, when I expand it, then the section states that no room detail is available rather than rendering an empty table.
  5. Given a room image fails to load, when the gallery renders, then the broken image is handled without collapsing the row layout.
Implementation notes

The collapsible Available Rooms block inside MetasearchResults.vue: room name, up to three preview images, and a rates table with total/per-night, Refundable tags and "Cancel by" dates.

Edge cases & risks

Room names are advertiser strings, not canonical HotelRoom records from canonicalData6, so "Deluxe Double" on one OTA and "Superior Queen" on another may or may not be the same physical room — cross-advertiser room matching is not attempted anywhere in the product today.

META-10

Mirror the query in the URL and auto-search deep links

Built
Data analystShould
As a Data analyst, I want a metasearch query to live in the URL, so that I can bookmark it, paste it into a ticket and have a colleague see exactly what I saw.
Acceptance criteria
  1. Given I run a search, when it executes, then every filter parameter is written into the route query string.
  2. Given I open a URL that already carries the parameters, when the page loads, then the filter panel is populated and the search runs automatically without a second click.
  3. Given several searches are fired in quick succession, when an earlier response arrives after a later one, then the stale response is discarded via the request key and the panel shows only the newest result set.
  4. Given I clear the filters, when the panel resets, then the query string is cleared too, so a stale URL cannot resurrect a search I abandoned.
  5. Given a deep link carries an invalid parameter combination, when the page loads, then a validation message is shown instead of firing an unusable upstream request.
Implementation notes

views/Metasearch.vue mirrors search params into the URL query and auto-searches on load or deep link; in-flight responses are matched against a request key so stale results are dropped, in line with the portal README rule that "URLs must address resources".

Edge cases & risks

A shared URL replays a live query, so the recipient sees today's prices, not the prices that prompted the message — the link is reproducible in its parameters but not in its answer, which is the single most common misunderstanding of this screen.

META-11

Show skeletons while searching, and clear error and empty states

Built
ProspectMust
As a Prospect evaluating the product, I want the screen to tell me whether it is working, broken or genuinely empty, so that I do not conclude the coverage is poor when the request simply takes a few seconds.
Acceptance criteria
  1. Given a search is in flight, when the results panel renders, then skeleton placeholders are shown and no stale offers from the previous query remain on screen.
  2. Given the upstream call takes the documented 2–5 seconds, when the wait exceeds a moment, then the skeleton persists rather than the panel flashing an empty state mid-flight.
  3. Given the request fails, when the response is handled, then a PrimeVue Message error is rendered with the failure reason and the filters remain populated so I can retry without retyping.
  4. Given the search succeeds with zero offers, when results render, then "No results found." is displayed and is visually distinct from the error state.
  5. Given an error toast is also raised, when it renders, then it does not replace the in-panel error, so the reason survives the toast timeout.
Implementation notes

MetasearchResults.vue states: skeletons, error Message, and the copy "No results found."; the API docs record a typical 2–5 second response because the call fans out to live advertiser sources. Portal loading convention from the README is "no stale content, show skeleton".

Edge cases & risks

Zero offers is genuinely ambiguous — no advertiser sells this property, none sells it on these dates, or the identifier resolved to the wrong hotel — and the current copy does not distinguish them, so "No results found." absorbs both a data-quality failure and a legitimate answer.

META-12

Gate metasearch behind the FULL tier with an upgrade path

Built
Trial userMust
As a Trial user, I want to be told plainly that metasearch needs an upgrade and who to contact, so that I do not conclude the feature is broken.
Acceptance criteria
  1. Given my organisation is not FULL tier and holds no metasearch subscription, when I call GET /metasearch, then I receive a 402 carrying "Upgrade to access metasearch. Contact sales at: hello@ubio.ai".
  2. Given the same organisation, when I navigate to /metasearch in the portal, then the requiresMetasearchAccess route guard evaluates access.hasMetasearchAccess() and redirects me to Billing rather than rendering an empty pricing screen.
  3. Given I reach a metasearch surface embedded in a hotel profile, when access is missing, then the UpgradeRequired component renders with "View Billing" and "Contact Sales to Upgrade" actions.
  4. Given the 402 carries metasearch_topup_required, when the portal handles it, then it renders the prepaid top-up branch rather than the tier-upgrade branch.
  5. Given access is blocked, when the request is rejected, then no metasearch:view access-log row is written and no upstream call is made.
Implementation notes

allowsMetasearch is FULL-only in AccessTierService; the portal mirrors it with AccessManager.hasMetasearchAccess() (full tier or a metasearch subscription), route meta requiresMetasearchAccess on /metasearch, /hotels/:id/competitor-prices, /lists/:id/price-comparison and /ai/insights, plus METASEARCH_TOPUP_REQUIRED_REASON = 'metasearch_topup_required'.

Edge cases & risks

The guard redirects to Billing rather than showing the upgrade panel in place, so a deep link a colleague shared silently becomes a billing page with no explanation of what was being requested; the two 402 reasons also need distinct copy or a paying customer is told to "upgrade" when they only need a top-up.

META-13

Rate-limit metasearch per principal and return Retry-After

Built
DeveloperMust
As a Developer, I want a predictable, documented rate limit with a retry hint, so that my integration can back off correctly instead of hammering a live upstream service.
Acceptance criteria
  1. Given I exceed RATE_LIMIT_REQUESTS (default 100) within RATE_LIMIT_DURATION_SECONDS (default 60), when I call GET /metasearch, then I receive a 429 with a Retry-After header.
  2. Given the limit is keyed by principal, when two personal access tokens from the same organisation call concurrently, then each is metered separately because the key is the principal, not the org.
  3. Given REDIS_URL is configured, when the API runs multiple instances, then the counter is shared through Redis rather than held per-process in memory.
  4. Given I am rate-limited, when the 429 is returned, then no upstream call is made and no metasearch:view row is logged.
  5. Given I call any other endpoint while rate-limited on metasearch, then that call succeeds, because the limiter is applied only to GET /metasearch.
Implementation notes

rate-limiter-flexible with a Redis store when REDIS_URL is set, otherwise in-memory; configured by RATE_LIMIT_REQUESTS / RATE_LIMIT_DURATION_SECONDS and unit-tested via RateLimitService.

Edge cases & risks

Without REDIS_URL the limit is per-instance, so the effective ceiling scales with replica count and is not the documented number; per-principal keying also means an organisation can multiply its throughput simply by minting more PATs, which is a metering hole as much as a limiting one.

META-14

Resolve the hotel identifier consistently and always meter the view

Partial
Ubio finance opsMust
As Ubio finance ops, I want every metasearch query to be attributed to a hotel and counted, so that billing and usage reporting reflect what customers actually consumed.
Acceptance criteria
  1. Given hotelIdType=huId, when the request is handled, then the canonical record is loaded and its refs.googleHotelId is used for the upstream call.
  2. Given a huId whose canonical record has no refs.googleHotelId, when I search, then the request fails with an explanatory error naming the missing reference rather than returning an empty offer list.
  3. Given hotelIdType=googleHotelId, when the request is handled, then the upstream call proceeds directly — and today a metasearch:view row is not written for that path.
  4. Given the direct-googleHotelId path is fixed, when a search succeeds, then a metasearch:view row is written against the resolved huId so /stats and the metasearch allowance count both paths identically.
  5. Given a googleHotelId that maps to no canonical hotel, when the search runs, then the response makes clear the hotel is not in canonicalData6, so the offer data cannot be joined to a profile.
Implementation notes

GET /metasearch identifier handling plus AccessTracking writing metasearch:view into accessLogs; huId values are minted deterministically for GoogleHotels as sha1('31b54e6e4b#' + dsHotelId).slice(0,10), so the two identifier spaces are related but the reverse lookup still needs refs.googleHotelId on the canonical record.

Edge cases & risks

This is simultaneously a revenue leak and a reporting error — Usage.vue under-reports metasearch for API-first customers — and closing it will make some customers' usage appear to jump overnight, so it needs a communications plan as well as a code change.

META-15

Run metasearch inside the hotel profile without losing context

Built
Hotel ownerShould
As a Hotel owner, I want to price my property from its profile page, so that I do not have to copy an identifier into a separate screen to answer "what am I being sold for today?".
Acceptance criteria
  1. Given I am on a hotel profile, when I open the metasearch tab, then Metasearch.vue renders embedded via its embeddedHotelId prop with the hotel already selected.
  2. Given the embedded view, when it renders, then the hotel identifier controls are suppressed because the hotel is fixed by context.
  3. Given I run a search in the embedded view, when it executes, then the same GET /metasearch call and the same metasearch_executed telemetry event are used as on the standalone screen.
  4. Given I lack metasearch access, when I open the tab, then UpgradeRequired renders inside the tab rather than the whole profile redirecting to Billing.
  5. Given I change tabs and come back, when the tab remounts, then the previous parameters are still in effect rather than resetting to defaults.
Implementation notes

views/Metasearch.vue accepts an embeddedHotelId prop and is mounted by views/HotelDetails.vue for the metasearch tab; the standalone route is /metasearch with requiresMetasearchAccess.

Edge cases & risks

The embedded and standalone instances do not share filter state, so a user who sets a currency on one screen finds it reset on the other; the tab is also present on preview-access profiles where the surrounding content is padlocked, which makes the access model look inconsistent.

META-16

Price a date range in a single batched request

Proposed
Revenue managerMust
As a Revenue manager, I want to ask for one hotel across a range of check-in dates in one call, so that I can see how my position changes across the booking window without firing sixty rate-limited requests.
Acceptance criteria
  1. Given a hotel and a date range, when I submit a batch metasearch request, then I receive offers grouped by check-in date for every date in the range.
  2. Given the range exceeds the synchronous limit, when I submit it, then the request is accepted as an asynchronous job rather than being refused.
  3. Given some dates fail upstream, when the batch completes, then successful dates are returned alongside a per-date error list, so a single failure does not void the batch.
  4. Given the batch runs, when usage is metered, then it is counted per date priced, not per HTTP request, so batching does not become a way to avoid the allowance.
  5. Given I request a range longer than the permitted lookahead, when the request is validated, then it is rejected with the maximum stated.
Implementation notes

Builds directly on the existing GET /metasearch proxy and the checkIn + nights contract; the batching and job semantics can reuse the pattern already proven by direct pricing's refreshDirectPricingPrices{ batchId } and getDirectPricingPriceBatch(batchId) polling.

Edge cases & risks

A date-range batch multiplies upstream cost linearly, so it must be metered and rate-limited at the date level or it becomes the cheapest way to exhaust the metasearch service; long ranges also need partial-result streaming or the perceived latency becomes minutes rather than seconds.

META-17

Price a whole list of hotels in one sweep

Proposed
OTA supply managerMust
As an OTA supply manager, I want to price every hotel in a saved list for the same stay, so that I can find where our supply is uncompetitive across a market in one operation.
Acceptance criteria
  1. Given a list of hotels, when I request a multi-hotel metasearch for a stay, then I receive results keyed by huId covering every entry that carries a resolvable refs.googleHotelId.
  2. Given entries without a resolvable reference, when the sweep completes, then they are reported as skipped with a reason, exactly as unmatched list entries are surfaced today.
  3. Given the list is large, when the sweep runs, then progress is reported incrementally rather than the caller waiting for the whole set.
  4. Given the sweep would exceed my remaining metasearch allowance, when I submit it, then I am warned with the projected consumption before it starts, not after.
  5. Given the sweep completes, when I export it, then I get one row per hotel per advertiser in CSV or JSON.
Implementation notes

Joins the existing Lists domain (GET /lists/{listId}/hotels, listEntries) to the metasearch proxy; the "Compare metasearch" action already present in the search selection rail and ListHeader.vue's "Compare prices" button are the natural entry points, and /list-price-matrix shows the aggregate result shape is already understood.

Edge cases & risks

Cost control is the whole design: a thousand-hotel list priced across a weekend is a large upstream bill, so pre-flight allowance estimation and a hard confirmation step matter more than throughput.

META-18

See cheapest rate by date as a calendar heat map

Proposed
Hotel ownerShould
As a Hotel owner, I want a month-view calendar shaded by the cheapest advertised rate for each date, so that I can spot soft dates at a glance instead of reading a table.
Acceptance criteria
  1. Given a hotel and a month, when the calendar renders, then each date cell shows the cheapest advertised total and is shaded on a gradient from cheapest to priciest in the visible window.
  2. Given a date has no offers, when the cell renders, then it is visually distinct from a cheap date rather than being shaded as the cheapest.
  3. Given I click a date, when the detail opens, then the full offer list for that check-in is shown using the existing offer fieldsets.
  4. Given weekends, when the calendar renders, then they are shaded distinctly, consistent with the weekend shading already used in the competitor price matrix.
  5. Given the underlying data is older than the freshness threshold, when the calendar renders, then the staleness is indicated per date rather than implied to be live.
Implementation notes

Reuses the heat-gradient, weekend-shading and per-column-average vocabulary already implemented in views/CompetitorPriceMatrix.vue, fed by the batched date-range query in META-16 rather than by live per-cell calls.

Edge cases & risks

A calendar invites the assumption that every cell was measured at the same moment; without a per-cell timestamp the shading compares prices captured days apart and will occasionally show a "soft date" that is simply a stale reading.

META-19

Normalise board basis and cancellation policy across advertisers

Proposed
Data analystMust
As a Data analyst, I want board basis and cancellation terms expressed in a common vocabulary, so that a price comparison across advertisers is actually a like-for-like comparison.
Acceptance criteria
  1. Given offers from several advertisers, when they are returned, then each carries a normalised boardBasis from a fixed enumeration (room only, breakfast, half board, full board, all inclusive) alongside the advertiser's original string.
  2. Given an offer's cancellation terms, when they are returned, then they are expressed as a normalised policy with a deadline timestamp and a penalty description, alongside the original text.
  3. Given an advertiser's string cannot be mapped, when the offer is returned, then the normalised field is null and the original is preserved rather than a wrong value being guessed.
  4. Given normalisation is applied, when I filter results to breakfast-included refundable offers, then only offers whose normalised fields match are returned.
  5. Given a mapping is corrected, when the change ships, then previously archived offers can be re-normalised without re-querying advertisers.
Implementation notes

Directly analogous to the existing source-mapping layer — FeaturesMapGoogle.ts (1166 lines), the Booking and TripAdvisor feature maps and MentionRatingMap.ts — applied to commercial terms instead of amenities; the Breakfast tag already used in the direct-pricing detail rows is the target vocabulary.

Edge cases & risks

Cancellation policy is legally material, so a wrong normalisation is worse than no normalisation; keeping the original string beside every mapped value is a hard requirement, not a debugging convenience.

META-20

Detect loyalty, member and logged-in-only rates

Proposed
OTA product managerShould
As an OTA product manager, I want to know when a competitor's headline price is a member-only rate, so that I do not chase a discount that is not available to an anonymous shopper.
Acceptance criteria
  1. Given an offer that requires sign-in or membership, when it is returned, then it carries a memberRate indicator with the programme name where the advertiser exposes one.
  2. Given both public and member rates exist for the same room, when results render, then both are shown with the gap between them stated.
  3. Given member-rate status cannot be determined, when the offer renders, then it is marked unknown rather than assumed public.
  4. Given I filter to public rates only, when the search runs, then member-only offers are excluded from the comparison and from any derived statistics.
  5. Given a parity check runs, when member rates are present, then they are excluded by default from parity violation detection.
Implementation notes

Extends the ProviderOffer shape returned through METASEARCH_SERVICE_URL and the Hotel / Sponsored tag vocabulary already rendered by MetasearchResults.vue; the deviceType parameter shows the upstream already models shopper context.

Edge cases & risks

Member rates are the single largest source of apparent parity breaches, so mislabelling them produces false alarms that erode trust in the alerting proposed in META-21; detection is also advertiser-specific and will degrade silently when an advertiser changes its storefront.

META-21

Detect rate-parity violations and alert on them

Proposed
Hotel ownerMust
As a Hotel owner, I want to be told when an OTA undercuts my own direct rate, so that I can act inside the booking window rather than discovering it in a monthly report.
Acceptance criteria
  1. Given a hotel with both metasearch offers and direct pricing enabled, when a sweep runs, then any advertiser total below the direct total for the same stay, occupancy and normalised board basis is flagged as a parity violation with the absolute and percentage gap.
  2. Given a violation is flagged, when I open it, then I see the advertiser, the dates, both prices, the room and rate plan on each side, and the capture timestamps.
  3. Given the comparison cannot be made like-for-like — different board basis, member rate, or missing direct data — then no violation is raised and the reason is recorded.
  4. Given I configure an alert threshold, when a violation exceeds it, then a notification is raised through the configured channel.
  5. Given a violation resolves on a later sweep, when the next run completes, then the alert is closed rather than left open indefinitely.
Implementation notes

Joins the metasearch offer stream to GET /direct-pricing/prices/:huId; the landing page already sells this exact promise — "Spot who is undercutting you, on which dates, and by how much, before the booking window closes" — so the product claim exists ahead of the capability.

Edge cases & risks

Parity claims are commercially and sometimes contractually sensitive, so evidence retention (META-22) and like-for-like discipline (META-19, META-20) are prerequisites; alerting without them generates disputes the platform cannot substantiate.

META-22

Archive offers so prices can be compared over time

Proposed
Data analystMust
As a Data analyst, I want every offer we retrieve to be retained with its capture timestamp, so that I can analyse how prices moved rather than only what they are right now.
Acceptance criteria
  1. Given a metasearch query completes, when offers are returned, then each is written to an offer archive with the query parameters, the advertiser, the prices, the normalised terms and a capture timestamp.
  2. Given an archived query, when I re-request the same parameters, then I can choose a cached archived answer or a fresh live call, and the response states which I received.
  3. Given a retention period is configured, when it elapses, then archived offers are removed on a documented schedule.
  4. Given the archive is queried, when I request a time series for a hotel and stay date, then I receive one point per capture rather than a single latest value.
  5. Given archiving fails, when the query completes, then the live response is still returned to the caller and the archive failure is logged rather than surfaced as a query error.
Implementation notes

MongoDB time-series is already in use for accessLogs (timeField: time, hourly granularity, one-year TTL), which is a direct precedent for an offer archive; the fluctuation analysis already rendered in HotelPriceInsights.vue implies snapshots are being kept somewhere upstream, so this makes that store a first-class, queryable product surface.

Edge cases & risks

Volume is the risk — offers per hotel per date per advertiser per capture grows multiplicatively — so retention, granularity and per-organisation scoping have to be decided before the first write, not after.

META-23

Normalise tax and fee inclusive versus exclusive pricing

Proposed
Corporate travel buyerMust
As a Corporate travel buyer, I want to compare all-in prices, so that an advertiser that hides resort fees does not look cheaper than one that does not.
Acceptance criteria
  1. Given an offer, when it is returned, then it carries a base amount, an itemised tax and fee breakdown where available, and a total, each labelled inclusive or exclusive.
  2. Given fees are payable at the property rather than at booking, when the offer renders, then those amounts are shown separately from the prepaid total.
  3. Given the breakdown is unavailable, when the offer renders, then the total is marked as "as advertised" rather than being presented as all-in.
  4. Given I switch the comparison to all-in mode, when results render, then offers are ordered by the comparable total and any offer lacking a breakdown is visibly excluded from the ordering.
  5. Given derived statistics are computed, when they use offer prices, then they state which basis was used.
Implementation notes

Extends ProviderOffer and the price rendering in MetasearchResults.vue; the same basis flag has to propagate into the price insights and matrix surfaces in the PINS epic, or the two products will disagree about the same hotel.

Edge cases & risks

Tax treatment varies by market and by whether the shopper is a consumer or a business, so a single all-in number is a simplification; presenting it without the breakdown would trade one misleading comparison for another.

META-24

Show currency conversion provenance and price freshness

Proposed
DeveloperShould
As a Developer, I want to know whether a price was quoted natively or converted, and how old it is, so that I can decide whether to trust it in my own product.
Acceptance criteria
  1. Given an offer priced in a currency the advertiser did not quote natively, when it is returned, then it carries the original currency, the original amount, the FX rate applied, the rate source and the rate timestamp.
  2. Given an offer quoted natively in the requested currency, when it is returned, then it is marked as unconverted.
  3. Given an offer, when it is returned, then it carries a capture timestamp and a freshness classification so a consumer can distinguish a live read from a cached one.
  4. Given a cached answer is served, when it is returned, then the response states the cache age and the API surfaces it in a header as well as the body.
  5. Given the FX source is unavailable, when a conversion would be required, then the offer is returned in its native currency with the conversion omitted rather than converted at a stale unattributed rate.
Implementation notes

Extends the currency handling on GET /metasearch and the offer schema; freshness pairs with the archive in META-22, and the "Updated" timestamp column already shown per day in DirectPricesDetail.vue is the established pattern for surfacing capture age in this product.

Edge cases & risks

Silent conversion is the current default assumption of every consumer of this API, so introducing explicit provenance will reveal that some historical comparisons were made across mixed bases; that is a correction worth making, but it needs to be communicated rather than shipped quietly.

META-25

Filter and exclude advertisers per query and per organisation

Proposed
OTA supply managerShould
As an OTA supply manager, I want to include or exclude specific advertisers, so that I can compare against the competitive set that matters to me rather than against the whole market.
Acceptance criteria
  1. Given a metasearch query, when I supply an advertiser include or exclude list, then only matching offers are returned and the exclusions are echoed in the response.
  2. Given my organisation configures a default exclusion set, when any query runs without an explicit list, then the default applies and is stated in the response.
  3. Given exclusions are applied, when derived statistics such as cheapest price or median are computed, then they are computed over the filtered set and labelled accordingly.
  4. Given an exclusion removes every offer, when results render, then the empty state states that filters removed all offers rather than showing the generic "No results found."
  5. Given an advertiser name arrives in an unrecognised spelling, when filtering is applied, then it is matched against a canonical advertiser identifier rather than a display string.
Implementation notes

Requires a canonical advertiser registry — the same normalisation problem already solved for data sources via DS_NAMES and the 32 REF_EXTRA_OTA_NAMES in the portal's types/hotel.ts, which is the obvious seed list; filtering itself then sits in front of MetasearchResults.vue and in the GET /metasearch query.

Edge cases & risks

Excluding your own brand is the most common use, and doing it by display-name matching will silently fail whenever an advertiser rebrands a storefront; without a canonical registry this feature degrades into a per-customer string-matching support burden.

META-26

Offer a documented asynchronous job API for large price sweeps

Proposed
AI agent builderShould
As an AI agent builder, I want to submit a large price sweep and poll for it, so that my agent is not blocked for minutes on a synchronous call it cannot stream.
Acceptance criteria
  1. Given a sweep specification of hotels, dates and occupancies, when I submit it, then I receive a job identifier immediately with an estimated cost in metasearch allowance units.
  2. Given a job identifier, when I poll the job endpoint, then I receive status, progress counts and partial results already available.
  3. Given the job completes, when I fetch the result, then I can retrieve it as a stream or as an export artefact rather than a single oversized JSON body.
  4. Given a job is cancelled, when I cancel it, then in-flight work stops and the allowance consumed to that point is reported.
  5. Given a job fails partway, when I inspect it, then completed and failed units are itemised so I can resubmit only the failures.
Implementation notes

Follows the existing asynchronous idioms in the platform — the export pipeline (POST /lists/{listId}/exports → poll → signed download URL) and the direct-pricing refresh batch ({ batchId } polled every three seconds) — rather than inventing a new job model; the MCP tool surface (prices.metasearch) is the intended first consumer, matching the landing claim that "long-running searches surface partial state instantly".

Edge cases & risks

Jobs make it easy to queue far more upstream work than the rate limiter was designed for, so allowance reservation at submission time — not consumption time — is required, otherwise concurrent jobs can each pass a pre-flight check and collectively blow the budget.

PINS 12 built1 partial13 proposed

Price insights, comp sets & market analytics

Where metasearch answers "what is this hotel selling for right now", price insights answers "and is that the right number". GET /hotels/:huId/price-insights drives the 960-line HotelPriceInsights.vue — a pricing-action recommendation, seasonality and weekday charts, volatility and fluctuation analysis, and an on-demand competitor comparison whose comp set is tuned through the weighted CompetitorCriteria.vue panel and rendered date-by-date in CompetitorPriceMatrix.vue. GET /market-insights extends the same idea from one property to a whole saved list. The built stories describe a strong descriptive analytics layer; the proposed stories add the things a revenue manager needs before they will act on it — durable comp sets, scheduled history, forecasting, alerting and honest uncertainty.

PINS-01

Load price insights for a property

Built
Revenue managerMust
As a Revenue manager, I want one call that returns everything known about my property's pricing position, so that I can open a single screen instead of assembling the picture from raw offers.
Acceptance criteria
  1. Given a hotel with calendar pricing data, when the Price Insights tab opens, then GET /hotels/:huId/price-insights is called with months: 12 and compset: false and the response populates HotelDetailsManager.priceInsights.
  2. Given the call is in flight, when the tab renders, then loading state is shown and no stale insight from a previously viewed hotel remains on screen.
  3. Given the property has too little pricing history, when the response is rendered, then the empty state "There isn't enough calendar pricing data for this property yet." is shown rather than charts drawn from two data points.
  4. Given the organisation lacks metasearch access, when the call returns 402, then priceInsightsUpgradeRequired or priceInsightsTopUpRequired is set and the corresponding panel renders instead of an error.
  5. Given the call fails for any other reason, when the failure is handled, then an error toast is raised and the tab shows a retry path rather than a blank panel.
Implementation notes

ApiManager.getPriceInsights(huId, {months, compset, criteria})GET /hotels/:huId/price-insights, orchestrated by HotelDetailsManager.loadPriceInsights(huId); rendered by components/hotel/HotelPriceInsights.vue on the insights tab of views/HotelDetails.vue.

Edge cases & risks

The competitor block is deliberately deferred (compset: false on first load) because it is expensive, so the initial screen answers "how am I priced" without "compared to whom" — users routinely read the first half as the complete answer.

PINS-02

Recommend a pricing action with a stated rationale

Built
Hotel ownerShould
As a Hotel owner, I want the screen to tell me what it thinks I should do and why, so that I get a recommendation rather than a pile of statistics to interpret myself.
Acceptance criteria
  1. Given price insights load, when the callout renders, then it states one of raise, soften or hold as the recommended action.
  2. Given an action is recommended, when the callout renders, then the rationale supplied by the API is displayed alongside it in plain language.
  3. Given the data is insufficient to recommend anything, when the callout renders, then it is suppressed rather than defaulting to hold as if that were a considered judgement.
  4. Given the recommendation is shown, when I read the surrounding panels, then the statistics that drove it — trend, volatility, weekend premium, competitor position — are visible on the same screen.
  5. Given I change the competitive set, when insights reload, then the recommendation is recomputed rather than left showing the previous comp set's conclusion.
Implementation notes

The pricing-action callout at the top of HotelPriceInsights.vue, driven by the raise / soften / hold value and rationale returned by GET /hotels/:huId/price-insights.

Edge cases & risks

A single-word recommendation carries more authority than the underlying data supports — there is no confidence measure attached today (see PINS-25) — and it does not know the hotel's cost base, occupancy or strategy, so it is a signal, not advice.

PINS-03

Show the pricing overview: average rate, trend, volatility and weekend premium

Built
Revenue managerMust
As a Revenue manager, I want the headline shape of my pricing in four numbers, so that I can characterise the property before I look at any chart.
Acceptance criteria
  1. Given insights load, when the Overview block renders, then it shows the average nightly rate with its minimum and maximum across the analysed window.
  2. Given a trend is computed, when it renders, then the direction (up, down or flat) and the percentage change are both shown.
  3. Given volatility is computed, when it renders, then a qualitative level is shown together with the ± standard deviation figure it was derived from.
  4. Given a weekend premium exists, when it renders, then it is expressed as a percentage above the weekday average.
  5. Given any single statistic is unavailable, when the block renders, then that stat is omitted rather than rendered as zero or as a dash that reads like a real value.
Implementation notes

The Overview stat row in HotelPriceInsights.vue: average nightly rate with min–max, price trend direction and percentage, volatility level with ±stdDev, and weekend premium percentage, all from the price-insights response.

Edge cases & risks

Averaging across a twelve-month window mixes seasons, so a highly seasonal property shows a wide min–max and a high volatility level that reflects the calendar rather than instability in pricing; the statistics are also unweighted by availability, so sold-out dates distort them.

PINS-04

Position the property against the competitor median, by date

Built
Hotel ownerMust
As a Hotel owner, I want to see where I sit against my comp set and on exactly which dates I am out of line, so that I can fix specific dates rather than move my whole calendar.
Acceptance criteria
  1. Given I request the competitor comparison, when it loads, then my position relative to the competitor median is shown together with my rank and percentile within the comp set.
  2. Given dates where my rate is below the comp set, when the comparison renders, then they are listed under "Underpriced vs competitors" with both the absolute gap (gapAbs) and the percentage gap (gapPct).
  3. Given dates where my rate is above the comp set, when the comparison renders, then they are listed under "Overpriced vs competitors" with the same two figures.
  4. Given the comparison has run, when the competitors table renders, then each row shows the hotel name linking to its profile, the match percentage, the distance and the average rate.
  5. Given no comparable competitors with pricing data exist nearby, when the comparison completes, then "No comparable competitors with pricing data were found nearby." is shown rather than an empty ranking.
  6. Given the comparison is expensive, when I open the insights tab, then it is not run until I explicitly request it.
Implementation notes

The Competitor comparison block in HotelPriceInsights.vue — position versus median, rank and percentile, the underpriced and overpriced date lists, and the competitors table (Hotel, Match %, Distance, Avg rate) — requested on demand with the weights from CompetitorCriteria.vue and fetched through getPriceInsights(huId, {compset: true, criteria}); it links out to the full CompetitorPriceMatrix view.

Edge cases & risks

"Underpriced" is computed purely on rate, with no view of quality, occupancy or contracted rates, so a correctly positioned budget property adjacent to upscale competitors will be told repeatedly that it is underpriced; match percentage is also a composite of user-chosen weights, so two analysts can see different comp sets for the same hotel with no indication that the comparison is subjective.

PINS-05

Chart price by month and average rate by day of week

Built
Data analystShould
As a Data analyst, I want seasonality and weekday shape as charts, so that I can see the pattern rather than infer it from a table of numbers.
Acceptance criteria
  1. Given seasonality data, when the "Price by month" bar chart renders, then one bar per month is drawn on the seasonalityCanvas element with the currency labelled.
  2. Given weekday data, when the "Average rate by day of week" bar chart renders, then seven bars are drawn on the weekdayCanvas element in calendar order starting from Monday.
  3. Given a month or weekday has no data, when the chart renders, then the gap is drawn as absent rather than as a zero bar.
  4. Given I hover a bar, when the tooltip renders, then it shows the exact average rate and the number of observations behind it.
  5. Given the analysed window is shorter than twelve months, when the seasonality chart renders, then only the covered months are drawn and the window is stated.
Implementation notes

chart.js in HotelPriceInsights.vue with BarController, BarElement, CategoryScale, Chart, Filler, Legend, LinearScale, LineController, LineElement, PointElement, Tooltip registered; canvases seasonalityCanvas and weekdayCanvas. These and AiDataInsights.vue are the only two files in the portal that use chart.js.

Edge cases & risks

A monthly bar chart of forward-looking rates is not a seasonality measurement — it is a snapshot of what is currently advertised for each month, which is heavily distorted by how far ahead each month sits in the booking window.

PINS-06

Report how often the price changes, by booking horizon

Built
Revenue managerShould
As a Revenue manager, I want to know how frequently a rate moves and by how much at each booking horizon, so that I can judge how aggressively the market is repricing around me.
Acceptance criteria
  1. Given fluctuation data, when the "How often the price changes" block renders, then it shows the number of snapshots analysed and the span in days they cover.
  2. Given the same block, when it renders, then an overall change frequency percentage is shown.
  3. Given the by-horizon table, when it renders, then each horizon row shows the percentage of changes, the average move as ±%, and the predominant direction.
  4. Given fewer snapshots exist than the analysis requires, when the block renders, then it is suppressed with an explanation rather than reporting a change frequency derived from two readings.
  5. Given the analysis is shown, when I read it, then the snapshot count and span make clear the sampling basis rather than implying continuous observation.
Implementation notes

The fluctuation section of HotelPriceInsights.vue: snapshotsAnalyzed, spanDays, overall change frequency and a by-horizon table of Changes %, Avg move ±% and Direction.

Edge cases & risks

Change frequency is a function of the capture cadence as much as of the market — sampling daily cannot detect intraday moves — so a low frequency may mean a stable price or an infrequent scrape, and the screen does not distinguish them.

PINS-07

Surface the best upcoming dates and recent price drops

Built
Travel agency consultantShould
As a Travel agency consultant, I want to see the cheapest upcoming dates and anything that has just dropped, so that I can bring a client an opportunity without scanning a calendar.
Acceptance criteria
  1. Given insights load, when the "Best dates" block renders, then the cheapest upcoming dates are listed with each date's percentage below the average.
  2. Given recent price movements, when the drops block renders, then each entry shows the old price, the new price and the percentage change.
  3. Given no drops have occurred in the analysed window, when the block renders, then it states so rather than being hidden entirely.
  4. Given a listed date has since sold out, when I click through to metasearch for that date, then the absence of offers is shown honestly rather than the insight being treated as a live guarantee.
  5. Given the underlying snapshots are stale, when the block renders, then the analysis window is stated so the recency claim is bounded.
Implementation notes

The "Best dates & recent changes" block in HotelPriceInsights.vue, combining cheapest upcoming dates with a "% below avg" figure and recent price drops rendered as old → new with a percentage.

Edge cases & risks

"Recent price drop" is derived from archived snapshots, so a drop can be an artefact of a failed capture followed by a successful one; without capture-quality flags the block occasionally advertises a discount that never existed.

PINS-08

Tune the competitive set with weighted criteria

Built
Revenue managerMust
As a Revenue manager, I want to control how similarity is scored, so that the comp set reflects who I actually compete with rather than who happens to be nearby.
Acceptance criteria
  1. Given the criteria panel, when it renders, then stars, guest rating, property type, amenities and distance each expose a weighting level, including an off level that removes the factor entirely.
  2. Given I select a preset, when it applies, then all five factors move to that preset's levels in one action.
  3. Given I have changed criteria, when I click reset, then the defaults are restored without reloading the page.
  4. Given I apply new criteria, when the comparison reruns, then match percentages and comp-set membership are recomputed from the new weights.
  5. Given every factor is set to off, when I try to run the comparison, then it is blocked with an explanation rather than returning an arbitrary set of nearby hotels.
Implementation notes

components/hotel/CompetitorCriteria.vue — factors stars, guest rating, property type, amenities and distance, each with levels including off, plus presets and reset; the criteria object is passed through to getCompetitors, getPriceInsights and getCompetitorPriceMatrix.

Edge cases & risks

Criteria are per-view and are not persisted anywhere (see PINS-15), so every visit starts from defaults and two analyses of the same property are not reliably comparable; amenity weighting also depends on feature coverage, which varies by source.

PINS-09

Find comparable hotels near a property

Built
Hotel ownerMust
As a Hotel owner, I want the platform to propose nearby comparable hotels, so that I do not have to name my competitors from memory.
Acceptance criteria
  1. Given a hotel with coordinates, when I run "Find competitor hotels", then GET /hotels/:huId/competitors is called with a radius (10 km by default), a limit and the current criteria.
  2. Given results return, when they render, then each competitor row shows a match percentage plus star rating, user rating, property type, shared amenity count and distance chips.
  3. Given competitors are returned, when the map renders, then the subject hotel and its competitors are drawn as markers on the MapLibre map.
  4. Given I select competitors, when I click "Save to list", then a list is created or appended from the selected huId values.
  5. Given the hotel has no coordinates in HotelLocation, when I attempt the search, then it is blocked with an explanation rather than returning a global result set.
Implementation notes

ApiManager.getCompetitors(huId, {radius, limit, criteria})GET /hotels/:huId/competitors, with HotelDetailsManager.loadCompetitors and searchCompetitors; rendered by components/hotel/HotelLocation.vue and MapComponent.vue, and tracked as hotel_detail:competitor_search.

Edge cases & risks

Proximity search assumes location coverage, and meta.location.available is false for part of the corpus, so hotels missing coordinates are invisible as competitors and their absence is indistinguishable from genuine market emptiness.

PINS-10

Render the date × hotel competitor price matrix

Built
Revenue managerMust
As a Revenue manager, I want a grid of dates against competitors with my own column beside them, so that I can read my position across a whole month at once.
Acceptance criteria
  1. Given a hotel and a comp set, when the matrix loads, then rows are dates, columns are hotels, and the subject hotel has its own dedicated column.
  2. Given rates are present, when the grid renders, then each cell is shaded on a heat gradient from cheapest to priciest for that date, with a legend explaining the scale.
  3. Given a market median column, when it renders, then each cell's gap to the median is coloured by sign and magnitude as a ±%.
  4. Given weekend dates, when rows render, then they are shaded distinctly from weekdays.
  5. Given each column, when the footer renders, then a per-column average is shown together with the snapshot date for the subject hotel's own data.
  6. Given the matrix request fails, when the error is handled, then an error state with a Retry action is shown rather than an empty grid.
Implementation notes

views/CompetitorPriceMatrix.vue at /hotels/:id/competitor-prices (route meta requiresMetasearchAccess, props: route => ({ huId: route.params.id })), fed by getCompetitorPriceMatrix(huId, {months, criteria}); the matrix is plain HTML, not chart.js.

Edge cases & risks

Cells are populated from snapshots taken at different times for different hotels, so a heat gradient can encode capture timing as much as price; sparse comp sets also make the median column volatile from date to date.

PINS-11

Compare an entire list in the same matrix

Built
OTA supply managerShould
As an OTA supply manager, I want to run the price matrix over a saved list rather than a single hotel's comp set, so that I can review a market or a portfolio in one grid.
Acceptance criteria
  1. Given a saved list, when I open /lists/:id/price-comparison, then the same matrix component renders in list mode with the list's hotels as columns.
  2. Given list mode, when the matrix renders, then the comp-set criteria editor is hidden, because membership is defined by the list rather than by similarity weights.
  3. Given I am on a list, when I click "Compare prices" in the list header, then I am routed to the matrix; without metasearch access the button is disabled with an explanatory tooltip.
  4. Given the list contains hotels with no pricing data, when the matrix renders, then those columns are shown as empty rather than omitted silently, so coverage gaps are visible.
  5. Given the list is large, when the matrix renders, then the column count is bounded and the truncation is stated.
Implementation notes

The same views/CompetitorPriceMatrix.vue mounted as route name ListPriceComparison with props: route => ({ listId: route.params.id }), fed by getListPriceMatrix(listId, {months})GET /list-price-matrix?listId=; the entry point is the "Compare prices" action in ListHeader.vue.

Edge cases & risks

A wide list produces a grid that is unreadable without horizontal scrolling and expensive to populate, since each column implies pricing calls; there is no column sorting or pinning, so the subject-versus-market reading that works in hotel mode is weaker here.

PINS-12

Analyse market pricing across a saved list

Built
Data analystShould
As a Data analyst, I want aggregate statistics for a whole list, so that I can characterise a market rather than a single property.
Acceptance criteria
  1. Given a saved list and a window in months, when I click Analyze, then GET /market-insights?listId= is called and the summary cards render: Hotels analysed, Market average rate with median, Range as min–max, and Outliers.
  2. Given outliers, when they are computed, then a hotel is an outlier at ≥1.5 standard deviations from the mean and its ±σ value is shown as a tag.
  3. Given the analysis completes, when the chart renders, then a horizontal bar chart of "Average nightly rate by hotel" is drawn.
  4. Given the analysis completes, when the lists render, then Best value, Most expensive and Outliers are shown as three separate lists.
  5. Given no list is selected, when the screen loads, then "Choose a list above to analyze its market pricing." is shown rather than an empty chart.
  6. Given the list has no priced hotels, when the analysis runs, then a no-data state is shown rather than statistics computed over an empty set.
Implementation notes

views/AiDataInsights.vue at /ai/insights with route meta requiresMetasearchAccess, calling getMarketInsights(listId, {months}); chart.js horizontal bar chart plus the three ranked lists.

Edge cases & risks

Mean and standard deviation are sensitive to small samples and to mixed property classes, so a list combining hostels and luxury hotels will label the correct extremes as outliers; the analysis also does not weight by number of rooms or by observation count per hotel.

PINS-13

Warn when the metasearch allowance is exhausted

Partial
Organisation adminMust
As an Organisation admin, I want to be told when analytics stop returning data because we have used our allowance, so that my team does not report it as an outage.
Acceptance criteria
  1. Given the allowance is exhausted, when I open Price Insights, Market Insights or the price matrix, then a warning banner reading "You've reached your metasearch allowance…" is shown with a "View billing" button.
  2. Given a 402 carrying metasearch_topup_required, when the portal handles it, then the top-up branch renders rather than the tier-upgrade branch.
  3. Given the allowance is exhausted, when analytics render, then any partial data already loaded stays visible rather than the screen clearing to an upgrade panel.
  4. Given I top up, when I retry, then the analysis runs without a page reload being required.
  5. Given the allowance is nearly exhausted, when I run an analysis, then I am warned before the request rather than only after it fails.
Implementation notes

HotelDetailsManager.metasearchAllowance / metasearchAllowanceExhausted plus METASEARCH_TOPUP_REQUIRED_REASON = 'metasearch_topup_required'; warning banners are rendered on HotelPriceInsights.vue, AiDataInsights.vue and CompetitorPriceMatrix.vue.

Edge cases & risks

Because these three screens each consume allowance implicitly, a single analyst exploring comp sets can exhaust an organisation's monthly budget in an afternoon with no running total visible anywhere in the portal.

PINS-14

Suggest a comp set automatically from behaviour and similarity

Proposed
Hotel ownerShould
As a Hotel owner, I want the platform to propose a comp set and explain why each hotel is in it, so that I can start from a sensible default instead of tuning five sliders.
Acceptance criteria
  1. Given a hotel, when I request a suggested comp set, then I receive a ranked set with a per-hotel explanation naming the factors that qualified it.
  2. Given a suggestion, when I review it, then I can remove or pin individual hotels and the suggestion adapts without discarding my edits.
  3. Given the hotel has thin feature or rating coverage, when suggestions are generated, then the reduced confidence is stated rather than the set being presented as equally reliable.
  4. Given I accept a suggestion, when I save it, then it becomes a saved comp set as defined in PINS-15.
  5. Given no viable candidates exist, when suggestions are generated, then the existing "No comparable competitors with pricing data were found nearby." message is used rather than a padded low-quality set.
Implementation notes

Builds on GET /hotels/:huId/competitors and the weighting model in CompetitorCriteria.vue, adding an explanation payload; the platform already computes composite similarity scores in packages/link (nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accept at ≥0.7), which is a proven pattern for weighted, explainable scoring here.

Edge cases & risks

Suggestions inherit the corpus's coverage bias — hotels with rich Booking or TripAdvisor data score better on amenity similarity purely because more is known about them — so an automatic comp set can systematically over-represent well-covered properties.

PINS-15

Make comp sets first-class saved objects

Proposed
Revenue managerMust
As a Revenue manager, I want to save, name and reuse a comp set, so that my analysis is reproducible week to week and comparable with my colleagues'.
Acceptance criteria
  1. Given a comp set, when I save it with a name, then it is persisted against the organisation and the subject hotel and is retrievable by identifier.
  2. Given a saved comp set, when I open Price Insights or the price matrix, then I can select it and the analysis runs against exactly those hotels.
  3. Given a saved comp set, when I edit its membership or its criteria, then the change is versioned so past analyses remain interpretable.
  4. Given a comp set member is later merged or removed from canonicalData6, when the set is loaded, then the affected member is flagged rather than silently dropped.
  5. Given a colleague in the same organisation opens the hotel, when they load insights, then the organisation's saved comp set is available to them.
Implementation notes

A new collection alongside lists / listEntries, which already model an org-scoped named collection of hotels with entries — a comp set is close to a list with weights and a subject hotel attached, and could reuse the same permission model (LISTS_MANAGE) and the priceCompetitiveSet field already present on HotelDetailsManager.

Edge cases & risks

Versioning is the part that is easy to skip and expensive to retrofit: without it, a comp set edited in March makes every February analysis unreproducible, which undermines the scheduled history proposed in PINS-16.

PINS-16

Schedule rate shopping and keep a historical time series

Proposed
Revenue managerMust
As a Revenue manager, I want my comp set priced automatically on a schedule, so that I have a consistent history rather than whatever I happened to look up.
Acceptance criteria
  1. Given a saved comp set and a set of stay dates, when I define a schedule, then prices are captured at that cadence without anyone opening the portal.
  2. Given a schedule runs, when captures complete, then each is stored with its timestamp so a time series per hotel per stay date is queryable.
  3. Given a scheduled run would exceed the organisation's metasearch allowance, when it is due, then it is skipped or partially executed with an explicit notification, never silently overrunning the budget.
  4. Given a capture fails, when the run completes, then the failure is recorded against that hotel and date so gaps in the series are explainable.
  5. Given a schedule is paused or deleted, when the next window arrives, then no capture occurs and no allowance is consumed.
Implementation notes

The scrapers package already runs PeriodicTaskRunner.schedule(task, interval, name) with an interruptible Sleepbox, batch limit 100 and concurrency 50, which is the natural execution model; storage pairs with the offer archive proposed in META-22.

Edge cases & risks

Scheduled shopping converts an interactive cost into a standing cost, so allowance accounting, per-organisation caps and a visible forecast of monthly consumption have to ship with it rather than after it.

PINS-17

Add demand signals: events, holidays and school breaks

Proposed
Revenue managerShould
As a Revenue manager, I want to see why a date is expensive, so that I can distinguish a genuine demand event from a competitor's mistake.
Acceptance criteria
  1. Given a date in the calendar or matrix, when demand signals exist, then the date is annotated with the event, public holiday or school break driving it, with the source named.
  2. Given a signal, when it renders, then it is scoped to the hotel's market by location, not applied globally.
  3. Given no signal exists for a date, when it renders, then it is left unannotated rather than labelled "normal demand".
  4. Given signals are present, when insights are computed, then event dates can be excluded from baseline statistics such as the weekend premium and volatility.
  5. Given a signal source is unavailable, when the calendar renders, then prices still render and the annotation layer degrades quietly.
Implementation notes

An annotation layer over the date axis already shared by CompetitorPriceMatrix.vue and the seasonality chart; hotel market scoping can use HotelLocation.countryCode, city and coordinates, and the COUNTRIES reference data already in shared provides the country metadata to anchor holiday calendars.

Edge cases & risks

Event data is a third-party dependency with licensing and accuracy constraints, and a wrong or missing event is worse than none because it manufactures a confident explanation for a price the platform does not actually understand.

PINS-18

Forecast forward rates and expected position

Proposed
Revenue managerCould
As a Revenue manager, I want an expected rate range for upcoming dates, so that I can see where the market is heading rather than only where it is.
Acceptance criteria
  1. Given sufficient history for a hotel and comp set, when I request a forecast, then I receive an expected rate with an interval for each forward date.
  2. Given a forecast, when it renders, then the history it was fitted on — snapshot count and span — is stated alongside it.
  3. Given insufficient history, when a forecast is requested, then it is declined with a stated minimum rather than extrapolated from a handful of points.
  4. Given a forecast exists, when actuals arrive, then forecast error is recorded so accuracy can be reported back to the user.
  5. Given a forecast is displayed, when it renders, then it is visually distinct from observed prices so nobody mistakes a projection for a measurement.
Implementation notes

Depends on PINS-16 for a real time series and on META-22 for the archive; the fluctuation analysis already in HotelPriceInsights.vue (snapshots analysed, span days, by-horizon change rates) is the closest existing statistical layer to build from.

Edge cases & risks

Forecasting the booking window without occupancy or pace data means modelling advertised prices, not demand, so the model will confidently reproduce advertiser behaviour including their errors; publishing accuracy metrics is the only defensible way to ship it.

PINS-19

Add pace and pickup analytics

Proposed
Revenue managerCould
As a Revenue manager, I want to see how availability is tightening for a date over time, so that I can price against pace rather than against a static rate comparison.
Acceptance criteria
  1. Given repeated captures for a stay date, when pace is computed, then the change in advertised availability over the booking window is shown per date.
  2. Given a comp set, when pace renders, then my pace is shown against the comp-set aggregate rather than in isolation.
  3. Given availability is not observable for an advertiser, when pace is computed, then that advertiser is excluded and the exclusion is stated.
  4. Given pace is displayed, when it renders, then the observation cadence is stated so the reader knows the resolution.
  5. Given fewer than the required number of captures exist, when pace is requested, then it is suppressed rather than drawn from two points.
Implementation notes

Requires the scheduled capture of PINS-16 plus availability signals; the direct-pricing side already models per-day availability with room counts in DirectPricesDetail.vue, which is the richer signal and the natural primary input where direct pricing is enabled.

Edge cases & risks

Advertised availability on an OTA is not the hotel's true inventory — allocations, closeouts and display caps all distort it — so pace derived from metasearch alone can be directionally wrong; the direct feed is more reliable but covers far fewer hotels.

PINS-20

Estimate price elasticity from observed movements

Proposed
Data analystCould
As a Data analyst, I want an estimate of how sensitive advertised availability is to price changes, so that I can quantify the trade-off before recommending a rate move.
Acceptance criteria
  1. Given a hotel with sufficient paired price and availability observations, when elasticity is estimated, then a coefficient is returned with a confidence interval and the sample size.
  2. Given the estimate, when it renders, then the window and the number of observations are stated on the same panel.
  3. Given the sample is too small or too collinear, when estimation runs, then no coefficient is returned and the reason is given.
  4. Given elasticity is available, when the pricing-action callout is generated, then it may cite the estimate as part of its rationale.
  5. Given the estimate is displayed, when it renders, then it is explicitly labelled as an observational estimate, not a causal one.
Implementation notes

Sits on top of PINS-16 and PINS-19; the pricing-action callout in HotelPriceInsights.vue is the natural consumer, since it currently recommends raise / soften / hold without any sensitivity input at all.

Edge cases & risks

Observational elasticity conflates demand and pricing strategy, so the coefficient will often reflect what revenue managers already did rather than what the market would bear; presenting it without an uncertainty interval would be actively harmful.

PINS-21

Alert when a competitor undercuts you beyond a threshold

Proposed
Hotel ownerMust
As a Hotel owner, I want to be notified when a competitor drops more than X% below me on a date I care about, so that I can respond inside the booking window.
Acceptance criteria
  1. Given a saved comp set, when I configure an alert with a percentage threshold and a date scope, then it is persisted against my organisation.
  2. Given a scheduled capture breaches the threshold, when the run completes, then an alert is raised naming the competitor, the dates, both rates and the gap.
  3. Given the same breach persists across runs, when subsequent captures complete, then the alert is updated rather than re-raised each time.
  4. Given the breach resolves, when the next capture completes, then the alert is closed with a timestamp.
  5. Given the comparison is not like-for-like — differing board basis, a member rate, or missing data — then no alert is raised and the suppression reason is recorded.
Implementation notes

Depends on PINS-16 for cadence and on META-19 / META-20 for like-for-like comparison; delivery would need a real notification layer, since the only outbound email today is sendInvitationEmail via SendGrid and there is no outbound webhook in the API clone despite the landing page advertising "push diffs to a webhook".

Edge cases & risks

Alert fatigue destroys this feature faster than any bug: without suppression of member rates, non-comparable boards and known promotional periods, a busy market will generate daily alerts that get filtered to a folder within a fortnight.

PINS-22

Send a weekly pricing digest by email

Proposed
Hotel ownerShould
As a Hotel owner, I want a short weekly summary of my position, so that I stay informed without logging in.
Acceptance criteria
  1. Given a subscribed hotel and comp set, when the weekly job runs, then an email is sent summarising position versus median, the largest gaps by date, notable drops and any open alerts.
  2. Given the digest, when it renders, then every figure links back to the relevant portal screen with the parameters already applied.
  3. Given nothing material changed, when the job runs, then a short "no significant change" digest is sent rather than a full report padded with noise.
  4. Given a recipient unsubscribes, when the next run occurs, then no email is sent to them and the preference is recorded per user, not per organisation.
  5. Given digest generation fails for one hotel, when the run completes, then the remaining hotels are still sent and the failure is logged.
Implementation notes

Requires generalising the SendGrid integration beyond sendInvitationEmail; the digest content maps directly onto the panels already in HotelPriceInsights.vue, so the work is scheduling, templating and preference storage rather than new analytics.

Edge cases & risks

Email introduces the platform's first standing outbound communication, which brings deliverability, unsubscribe compliance and data-protection obligations that the current single-template setup has never had to handle.

PINS-23

Publish benchmark reports by market and star band

Proposed
OTA product managerCould
As an OTA product manager, I want aggregate benchmarks for a market and star band, so that I can assess a property against its segment rather than only against a hand-picked comp set.
Acceptance criteria
  1. Given a market defined by city or a geographic radius and a star band, when I request a benchmark, then I receive median, quartile and range statistics with the sample size.
  2. Given a benchmark, when it renders, then the number of contributing hotels is shown and benchmarks below the minimum sample size are withheld.
  3. Given a hotel, when I view its insights, then its position within the relevant benchmark is shown as a percentile.
  4. Given benchmarks are published, when they are computed, then no individual hotel's rates are identifiable from the aggregate.
  5. Given a market has too few priced hotels, when a benchmark is requested, then it is declined with the threshold stated.
Implementation notes

Reuses the aggregation already proven by GET /market-insights and AiDataInsights.vue but scoped by search facets (location.city, stars[], _loc geo) rather than by a saved list, which the Atlas Search index atlas_custom_search_10 already supports directly.

Edge cases & risks

Aggregates built from a corpus with uneven pricing coverage will describe the priced subset, not the market; minimum sample sizes and confidentiality thresholds are required both for statistical honesty and to avoid re-identifying individual properties.

PINS-24

Export insight datasets for external analysis

Proposed
Data analystShould
As a Data analyst, I want to export the underlying insight data, so that I can join it to occupancy and revenue data in our own warehouse.
Acceptance criteria
  1. Given a hotel or a list and a window, when I request an insights export, then I receive a dataset containing per-date rates, comp-set membership, gaps to median and the derived statistics.
  2. Given the export, when it is produced, then CSV and JSON are both offered and the schema is documented.
  3. Given the export completes, when I download it, then the existing signed-URL flow and expiry apply.
  4. Given the export contains derived statistics, when it is produced, then each carries the sample size and window it was computed over.
  5. Given my organisation lacks metasearch access, when I request the export, then it is refused with the same 402 semantics as the interactive screens.
Implementation notes

Extends ExportService and the POST /lists/{listId}/exports → poll → getSignedDownloadUrl (v4, 15-minute validity) pipeline with a new export type alongside full, preview, reviews and the portal's source_data; the streaming gzip-to-GCS pipeline already runs in constant memory.

Edge cases & risks

Exported statistics detach from the assumptions that produced them, so the criteria, comp-set version and capture window have to travel inside the file; otherwise the same figures reappear in a customer's dashboard months later with no way to reconcile them.

PINS-25

Attach sample sizes and confidence intervals to every derived statistic

Proposed
Data analystMust
As a Data analyst, I want every number on the insights screen to carry its uncertainty, so that I can tell a solid conclusion from a coincidence.
Acceptance criteria
  1. Given any derived statistic — average rate, trend, volatility, weekend premium, change frequency, position versus median — when it is returned, then it carries the observation count and window it was computed over.
  2. Given a statistic where an interval is meaningful, when it is returned, then a confidence interval is included.
  3. Given a statistic falls below the minimum sample size, when it renders, then it is suppressed or explicitly marked low confidence rather than displayed as a plain number.
  4. Given a percentage change, when it renders, then it is suppressed when the base value is too small for the percentage to be meaningful.
  5. Given the pricing-action callout is generated, when the underlying statistics are low confidence, then the callout states its own reduced confidence rather than recommending with the usual certainty.
Implementation notes

A cross-cutting change to the GET /hotels/:huId/price-insights and GET /market-insights payloads and to every stat block in HotelPriceInsights.vue and AiDataInsights.vue; the fluctuation block already exposes snapshotsAnalyzed and spanDays, so the pattern exists and simply is not applied consistently.

Edge cases & risks

Applying this honestly will hide or qualify a large share of the numbers currently displayed for thinly covered hotels, which will look like a regression in product richness — that expectation needs managing, because the alternative is a screen that is confidently wrong.

PINS-26

Drill through from any statistic to the offers behind it

Proposed
Revenue managerShould
As a Revenue manager, I want to click a number and see the actual offers that produced it, so that I can verify a surprising figure before I act on it or report it.
Acceptance criteria
  1. Given any statistic, chart bar or matrix cell, when I click it, then I see the underlying offers with advertiser, room, rate plan, price and capture timestamp.
  2. Given a drill-through, when it renders, then excluded offers and the reason for exclusion are shown alongside the included ones.
  3. Given the underlying offers have been purged by retention, when I drill through, then the retention boundary is stated rather than an empty panel being shown.
  4. Given a drill-through, when I export it, then I get the exact rows behind the figure.
  5. Given an offer was a sponsored placement or a member rate, when it appears in a drill-through, then it is tagged as such using the same vocabulary as the metasearch results.
Implementation notes

Depends on the offer archive proposed in META-22; presentation can reuse the offer fieldsets already implemented in MetasearchResults.vue, which keeps the evidence view visually identical to the live view.

Edge cases & risks

Drill-through is the fastest way to expose every normalisation and capture-quality weakness in the pipeline to customers, which is exactly why it is valuable — but it should not ship before META-19 and META-24, or every click will surface a discrepancy the product cannot yet explain.

DARI 11 built3 partial12 proposed

Direct pricing (Direct ARI)

Direct ARI is the third product on the landing page and the one that closes the loop: "Room rates pulled straight from the hotel website and booking engine — per room, with full rate plans, LOS rules and cancellation policies. Up to 365-day lookahead", with the standing disclaimer that "These are the hotel's own room rates — not what Hotel Universe charges." In the portal it is a roster of enabled hotels (/direct-prices), a per-list coverage view (/direct-prices/lists), a daily price calendar per hotel (/direct-prices/:huId) and a set of ubio-internal screens that link a huId to a Smart Feed booking-engine record before any of it can work. The built stories describe a working but internally-operated pipeline; the proposed stories move linking, scheduling, normalisation and diagnostics out to the customer and make the direct feed comparable with the OTA feed.

DARI-01

Explain what Direct ARI is and whose rates these are

Built
ProspectMust
As a Prospect, I want the product to state plainly where direct rates come from and that they are the hotel's own prices, so that I understand what I am buying and do not mistake them for Hotel Universe's prices.
Acceptance criteria
  1. Given the landing page, when the Direct pricing product card renders, then it states that room rates, availability and inventory are pulled straight from the hotel's own website and booking engine, per room, with full rate plans, LOS rules and cancellation policies, and up to a 365-day lookahead.
  2. Given the same card, when it renders, then the disclaimer "These are the hotel's own room rates — not what Hotel Universe charges." is displayed.
  3. Given the card, when it renders, then Refresh cadence and Global coverage are shown as product attributes and a "Direct pricing guide" link points at the documentation.
  4. Given I am signed in, when I open the Direct ARI tab on a hotel profile, then the same ownership framing is available in context rather than only on the marketing page.
  5. Given a hotel has direct pricing available, when its profile hero renders, then a "Direct ARI available" badge is shown.
Implementation notes

LandingProducts with the MiniAri visual and DOCS_DIRECT_ARI_URL from components/landing/links.ts; in-app the directari tab on views/HotelDetails.vue and the "Direct ARI available" badge in HotelDetailHero.vue.

Edge cases & risks

The disclaimer only appears on the marketing surface, so an authenticated user who arrived by deep link may never see it; given the product's whole purpose is parity comparison, ambiguity about whose price is displayed is a commercially material risk.

DARI-02

Browse the direct-pricing roster and enable or disable hotels in bulk

Built
Organisation adminMust
As an Organisation admin, I want to see which hotels have direct pricing active and switch groups of them on or off, so that I can manage what we are paying to track.
Acceptance criteria
  1. Given /direct-prices?view=my, when the page loads, then only active hotels are listed; given ?view=all, then every hotel available to enable is listed, via getDirectPricingHotels(enabledOnly, offset, limit).
  2. Given the table renders, when a row is drawn, then it shows a selection checkbox, the hotel name linking to its profile, the Smart Feed hotel ID in monospace, a status tag of Active, Disabling <date> or Available, and a "Prices" link to the detail screen.
  3. Given I select hotels in the All view and click Enable, when I confirm the dialog, then POST /direct-pricing/enable is called and the rows move to active state.
  4. Given I select active hotels and click "Disable at billing period end", when I confirm, then the hotels are scheduled to disable and their status tag becomes Disabling <date> rather than deactivating immediately.
  5. Given a bulk action partially fails, when it completes, then a warning toast states how many succeeded and how many did not, rather than reporting blanket success.
  6. Given no hotels qualify, when the view renders, then "No active direct pricing hotels yet." or "No direct pricing hotels are available to enable yet." is shown as appropriate.
Implementation notes

views/DirectPrices.vue with infinite scroll, $confirm.require dialogs for both bulk actions, and ApiManager.enableDirectPricing(huIds) / disableDirectPricing(huIds); DirectPricingManager.loadActiveHotels() holds the active set.

Edge cases & risks

Disable is billing-cycle-end semantics, so a hotel stays active and billable after the user believes they have turned it off; the roster is also identifier-driven and shows the Smart Feed ID, which is meaningless to a customer who never sees the linking process.

DARI-03

Request, enable and disable direct pricing from the hotel profile

Built
Hotel ownerMust
As a Hotel owner, I want to turn direct pricing on for a single property from its profile, so that I do not have to find it again in a roster.
Acceptance criteria
  1. Given a hotel profile, when the right rail renders, then the current direct-pricing status is shown and the applicable action is offered: Request Direct Pricing, Enable, Re-enable, Cancel Scheduled Disable or Disable.
  2. Given I choose an action, when I click it, then a $confirm.require dialog states the billing-cycle-end semantics before anything changes.
  3. Given I confirm, when the call completes, then HotelDetailsManager.directPricingStatus is refreshed and the rail reflects the new state without a page reload.
  4. Given the hotel is not yet linked to a booking-engine record, when I act, then requestLink() is used rather than an enable that would silently produce no prices.
  5. Given the call fails, when the failure is handled, then an error toast is shown and the previous status is restored rather than the UI optimistically showing the new state.
Implementation notes

components/hotel/DirectPricingButton.vue inside HotelDetailRail.vue, backed by HotelDetailsManager.loadDirectPricingStatus(huId), requestLink(), enableDirectPricing() and disableDirectPricing(); DirectPricingManager.requestDirectPricing(huId) handles the request path.

Edge cases & risks

"Request" and "Enable" look almost identical to a user but mean very different things — one queues internal linking work with no service-level promise, the other starts billing — and the gap between requesting and rates appearing is invisible from this screen.

DARI-04

View a daily direct price calendar with a 180 or 365 day range and a length of stay

Built
Revenue managerMust
As a Revenue manager, I want to see a hotel's own rates day by day across the booking window for a chosen length of stay, so that I can review the direct calendar the way the booking engine presents it.
Acceptance criteria
  1. Given /direct-prices/:huId, when the page loads, then daily prices are fetched via getDirectPricingPrices(huId, {startDate, interval, nights, guests}) and rendered as one row per date.
  2. Given the range toggle, when I switch between 180 and 365 days, then the interval is re-requested and the table re-renders without losing the selected length of stay.
  3. Given the length-of-stay select, when I choose between 1 and 7 nights, then prices for that stay length are shown.
  4. Given the hotel is embedded in a profile tab, when the Direct ARI tab renders, then the same component is mounted through its embeddedHuId prop.
  5. Given no pricing exists for the whole range, when the table renders, then an empty state is shown rather than 365 blank rows.
Implementation notes

views/DirectPricesDetail.vue at route /direct-prices/:huId (name DirectPricesDetail), also embedded in views/HotelDetails.vue for the directari tab; the 365-day ceiling matches the landing page's "Up to 365-day lookahead" claim.

Edge cases & risks

A 365-day, 7-night request is a large fetch and a large render, and the screen has no virtualisation, so the longest range is also the slowest; length of stay and guest count are separate axes but only one is exposed, so the calendar is implicitly a fixed-occupancy view.

DARI-05

Show per-day availability and the last update time

Built
Hotel ownerMust
As a Hotel owner, I want each day to state how many rooms are bookable and when that was last checked, so that I can tell a sold-out date from a stale reading.
Acceptance criteria
  1. Given a date with inventory, when the row renders, then the Availability column shows the number of rooms available.
  2. Given a date with no inventory, when the row renders, then "No availability" is shown rather than a blank cell.
  3. Given any date, when the row renders, then an Updated timestamp shows when that day's data was last captured.
  4. Given a date has never been captured, when the row renders, then the Updated cell is empty and the status icon reflects the missing capture rather than showing a fresh state.
  5. Given the timestamps in a range vary widely, when the table renders, then each row keeps its own timestamp rather than a single range-level "last updated" being applied to all.
Implementation notes

The Availability and Updated columns of the daily table in views/DirectPricesDetail.vue, populated from GET /direct-pricing/prices/:huId.

Edge cases & risks

Availability from a booking engine is a display artefact — many engines cap the shown room count — so "9 rooms" may mean "at least 9"; treating it as true inventory would make the pace analytics proposed elsewhere in this backlog quietly wrong.

DARI-06

Show the From price with movement against the previous capture

Built
Revenue managerShould
As a Revenue manager, I want each day's lead-in price to show whether it moved since the last capture, so that I can spot changes without keeping my own records.
Acceptance criteria
  1. Given a date with rates, when the row renders, then the From column shows the lowest available price for the selected length of stay.
  2. Given the price changed since the previous capture, when the row renders, then an up or down arrow is shown and the previous price appears struck through beside the new one.
  3. Given the price is unchanged, when the row renders, then no arrow is shown rather than a neutral arrow that reads as movement.
  4. Given no previous capture exists, when the row renders, then the price is shown without movement decoration.
  5. Given a row is expanded, when the room-level rows render, then the same arrow and struck-through previous price treatment is applied per rate.
Implementation notes

The From column and the expanded Price cells in views/DirectPricesDetail.vue, both using the up/down arrow plus struck-through previous price pattern.

Edge cases & risks

Movement is measured against the previous successful capture, not against a fixed interval, so after a run of failures an arrow can compare prices weeks apart while looking like a same-day change; there is no history view behind the arrow (see DARI-26).

DARI-07

Show per-day capture status and why a day failed

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want each day to carry its capture status and failure reason, so that I can distinguish a booking engine that is down from a hotel that is genuinely sold out.
Acceptance criteria
  1. Given a date currently being refreshed, when the row renders, then a refreshing status icon is shown.
  2. Given a date captured successfully, when the row renders, then a fresh status icon is shown.
  3. Given a date whose capture failed, when the row renders, then a failure icon is shown with the reason available on hover or expansion.
  4. Given a failure reason, when it is shown to a customer, then it is expressed in terms they can act on rather than as an internal error string.
  5. Given many days failed in the same range, when the table renders, then a range-level summary of the failure is shown rather than the user having to hover each row.
Implementation notes

The per-day Status column in views/DirectPricesDetail.vue renders refreshing / fresh / failed icons with a reason, driven by the per-day payload from GET /direct-pricing/prices/:huId.

Edge cases & risks

A failed capture and a genuine closeout look similar at a glance, so a hotel owner can conclude their booking engine is showing no availability when in fact the scrape failed — and the resulting support ticket is expensive precisely because the distinction is buried in an icon tooltip.

DARI-08

Expand a day into room, rate plan and board rows

Built
Revenue managerMust
As a Revenue manager, I want to open a date and see each room and rate plan behind the lead-in price, so that I can check the mix rather than only the cheapest number.
Acceptance criteria
  1. Given a date row, when I expand it, then one row per room and rate plan is shown with Room, Rate, Board and Price columns.
  2. Given a rate that includes breakfast, when the row renders, then a Breakfast tag is shown in the Board column.
  3. Given a rate whose price moved, when the row renders, then the change arrow and struck-through previous price are shown at rate level.
  4. Given a date with availability but no rate breakdown, when I expand it, then the absence of detail is stated rather than an empty expansion.
  5. Given the expansion is open, when I change the length of stay or range, then the expansion state is reset rather than showing rates for a stay length no longer selected.
Implementation notes

The row-expansion panel in views/DirectPricesDetail.vue — Room / Rate / Board with a Breakfast tag, and Price with change arrows and struck-through previous prices.

Edge cases & risks

Rate-plan names come straight from each booking engine and are not normalised, so "BAR", "Flexible Rate" and "Best Available" appear as distinct plans across hotels, making cross-property comparison impossible without the normalisation proposed in DARI-16.

DARI-09

Refresh direct prices on demand, gated to the full tier

Partial
Revenue managerShould
As a Revenue manager, I want to force a fresh capture when I know something has changed, so that I am not acting on data from the last scheduled run.
Acceptance criteria
  1. Given access.hasDirectPricingRefreshAccess() returns true, when I open the detail screen, then the Refresh button is enabled.
  2. Given the same check returns false, when the screen renders, then the Refresh button is hidden or disabled with an explanation of what unlocks it.
  3. Given I click Refresh, when the request is made, then POST /direct-pricing/prices/:huId/refresh returns a { batchId } and a direct_prices:refresh telemetry event is emitted.
  4. Given a refresh is already running for this hotel, when I click Refresh again, then the existing batch is reused rather than a second batch being started.
  5. Given the refresh call fails, when the failure is handled, then an error is surfaced and the button returns to its idle state rather than remaining in a permanent spinner.
Implementation notes

AccessManager.hasDirectPricingRefreshAccess() gates the Refresh action in views/DirectPricesDetail.vue; DirectPricingManager.startPriceRefresh(huId, interval, nights) wraps ApiManager.refreshDirectPricingPrices(huId, range).

Edge cases & risks

On-demand refresh is the most expensive action in the product because it triggers live booking-engine work per date, and it is currently unmetered — the trial/full binary is the only control, so a full-tier customer can refresh a 365-day range repeatedly at no marginal charge.

DARI-10

Poll refresh batches and keep them alive across reloads

Built
Revenue managerShould
As a Revenue manager, I want a running refresh to keep reporting progress even if I navigate away or reload, so that I do not restart work that is already in flight.
Acceptance criteria
  1. Given a refresh batch has started, when it is running, then GET /direct-pricing/prices/batch/:batchId is polled every 3 seconds (POLL_INTERVAL_MS = 3000) and progress is displayed as "Refreshing n/N".
  2. Given the batch completes, when the final poll returns, then "Prices refreshed" is shown and the daily table is reloaded with the new values.
  3. Given I reload the page mid-refresh, when the screen mounts, then the active batch is recovered and polling resumes.
  4. Given a batch is older than 24 hours, when the persisted state is read, then it is discarded rather than polled indefinitely.
  5. Given polling fails repeatedly, when the failures accumulate, then polling stops with an error state instead of looping forever.
Implementation notes

DirectPricingManager.getActiveBatch(huId) / clearActiveBatch(huId) persist active batches to the localStorage key directPricingActiveBatches with a 24-hour TTL; the polling loop and the "Refreshing n/N" / "Prices refreshed" copy live in views/DirectPricesDetail.vue.

Edge cases & risks

Batch state is per-browser, so a colleague opening the same hotel sees no refresh in progress and can start a duplicate; localStorage is also cleared by privacy tooling, which orphans a running server-side batch with no client watching it.

DARI-11

Show direct-pricing coverage for every list

Built
OTA supply managerShould
As an OTA supply manager, I want to see at a glance how much of each list is covered by direct pricing, so that I know where the gaps are before I plan work around it.
Acceptance criteria
  1. Given /direct-prices/lists, when the page loads, then each list carries a Direct Pricing coverage tag from Processing, Unknown, No hotels, None active, Full coverage or Partial.
  2. Given partial coverage, when the tag renders, then it reads "X of Y active — Z to enable".
  3. Given a dynamic list whose membership is a live query, when coverage is computed, then the figures are prefixed with "~" to mark them as estimates.
  4. Given a coverage lookup fails, when the tag renders, then Unknown is shown rather than a coverage figure that would be wrong.
  5. Given an enable job is running for a list, when the tag renders, then Processing is shown and the tag refreshes when the job completes.
Implementation notes

views/DirectPriceLists.vue calling getDirectPricingListCoverage(listIds)POST /direct-pricing/list-coverage; dynamic lists carry a searchQuery so their membership is not fixed, which is why estimates are marked with "~".

Edge cases & risks

Coverage of a dynamic list is a moving target by design — the same list can be 80% covered today and 40% covered tomorrow because new hotels matched the query — so the "~" prefix carries a lot of meaning for a single character.

DARI-12

Enable direct pricing for a whole list as a job

Partial
OTA supply managerShould
As an OTA supply manager, I want to enable direct pricing across a list in one action, so that I do not enable several hundred hotels one at a time.
Acceptance criteria
  1. Given selected lists on /direct-prices/lists, when I click "Enable Direct Pricing" and confirm, then POST /direct-pricing/enable is called with the list and a { jobId } is returned.
  2. Given a job is running, when the coverage tag renders, then it shows Processing until the job finishes.
  3. Given the job completes, when coverage refreshes, then the tag reflects the new active counts.
  4. Given some hotels in the list cannot be enabled because they are not linked to a booking-engine record, when the job completes, then those hotels are reported as skipped with a reason rather than counted as enabled.
  5. Given the job fails, when its status is read, then the failure and the number of hotels affected are surfaced rather than the tag reverting to Unknown.
Implementation notes

ApiManager.enableDirectPricingByList(listId)POST /direct-pricing/enable returning { jobId }; entry points are the bulk action in views/DirectPriceLists.vue and the ubio-gated button in ListHeader.vue on the Confirmed Hotels tab.

Edge cases & risks

Enabling a large list commits an organisation to recurring cost with a single confirmation and no projected-spend figure, and because disable is billing-cycle-end, an accidental bulk enable cannot be fully reversed within the period.

DARI-13

Link a hotel to a booking-engine record manually

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to link a huId to a Smart Feed hotel or create one, so that direct pricing can start for a hotel a customer has requested.
Acceptance criteria
  1. Given /hotels/:huid/manual-ibe-matching and a ubio account, when the page loads, then the route guard requiresUbioAccess resolves via AccessManager.ensureUbioAccessResolved() and the screen renders; a non-ubio user is redirected home.
  2. Given Link existing mode, when I enter a Smart Feed hotel ID and submit, then POST /private/direct-pricing/link-manual is called with the huId and sfHotelId.
  3. Given a hotel that should not be linked, when I choose Reject, then POST /private/direct-pricing/reject-link records the decision so the hotel does not reappear in the queue.
  4. Given Create in Smart Feed mode, when I select an IBE from getDirectPricingIbes() and supply a point-of-sale URL, then POST /private/direct-pricing/import-hotel creates the record.
  5. Given a link attempt fails, when the error returns, then the reason is shown and no partial link is recorded.
Implementation notes

views/ManualIbeMatching.vue with route meta requiresUbioAccess; internal access is email-domain based (ub.io or ubio.ai), matching the API's isUbee() check. The three endpoints sit under /private/direct-pricing/*.

Edge cases & risks

The gate is an email-domain check, which is an identity heuristic rather than a role, so any account created on a ubio domain has write access to production linking; a wrong link silently attributes another property's rates to a customer's hotel, which is the worst failure mode in this product.

DARI-14

Work through a list's unlinked hotels with CSV round-trip

Built
Ubio data ops engineerShould
As a Ubio data ops engineer, I want to export a list's unlinked hotels, match them offline and upload the result, so that I can clear a large backlog without linking hotels one by one.
Acceptance criteria
  1. Given /lists/:listId/manual-ibe-matching, when the page loads, then unlinked hotels for that list are listed via GET /lists/:id/direct-pricing/unlinked with paging.
  2. Given the list, when I click "Download CSV", then GET /lists/:id/direct-pricing/unlinked/export returns a file of the unlinked hotels.
  3. Given a completed matches file, when I upload it, then POST /lists/:id/direct-pricing/link-csv returns {linked, failed, errors} and the counts are displayed.
  4. Given rows fail to link, when the result renders, then the per-row errors are shown so they can be corrected and re-uploaded.
  5. Given every hotel in the list is linked, when the page loads, then "All hotels matched" is shown rather than an empty table.
Implementation notes

views/ListManualIbeMatching.vue, also guarded by requiresUbioAccess, using getListUnlinkedHotels(listId, offset, limit), downloadListUnlinkedCsv(listId) and uploadListIbeLinkCsv(listId, file).

Edge cases & risks

A CSV round trip has no optimistic-concurrency check, so two operators working the same list can link the same hotel to different Smart Feed records; the upload result is also transient — there is no persisted audit of who linked what, which matters given the linking failure mode described in DARI-13.

DARI-15

Let hotel owners link their own booking engine

Proposed
Hotel ownerMust
As a Hotel owner, I want to connect my own booking engine myself, so that direct pricing starts in minutes rather than waiting on a manual internal process.
Acceptance criteria
  1. Given I own a hotel in the platform, when I start self-serve linking, then I choose my booking engine from the same IBE catalogue used internally and supply my point-of-sale URL.
  2. Given I submit, when the platform validates the URL, then it performs a test capture and shows me the rooms and rates it found before anything is saved.
  3. Given the test capture returns the wrong property, when I review it, then I can reject the match and correct the URL rather than the link being committed automatically.
  4. Given the link is confirmed, when it is saved, then it is recorded with who created it and when, and it appears in the internal queue as self-linked rather than unlinked.
  5. Given the booking engine is not supported, when I select it, then I am told so explicitly and offered the existing request path instead of a silent failure.
Implementation notes

Exposes the internal flow already implemented in views/ManualIbeMatching.vue — IBE select from getDirectPricingIbes() plus a PoS URL, then importDirectPricingHotel — as a customer-facing screen with a verification step, rather than building a new pipeline.

Edge cases & risks

Self-serve linking without ownership verification lets any customer attach any hotel's booking engine, which is why DARI-25 (consent and ownership records) is a hard prerequisite rather than a companion feature.

DARI-16

Normalise rate plans and cancellation policies across booking engines

Proposed
Data analystMust
As a Data analyst, I want rate plans and cancellation terms expressed consistently across booking engines, so that I can compare hotels rather than compare vendors' naming conventions.
Acceptance criteria
  1. Given a rate plan, when it is returned, then it carries a normalised classification (for example fully flexible, semi-flexible, non-refundable, advance purchase) alongside the engine's original name.
  2. Given a cancellation policy, when it is returned, then it is expressed as a deadline plus a penalty structure, alongside the original policy text.
  3. Given board arrangements, when they are returned, then they use the same normalised vocabulary as the metasearch board-basis normalisation in META-19.
  4. Given a plan cannot be classified, when it is returned, then the normalised field is null and the original is preserved rather than a guess being made.
  5. Given normalisation improves, when mappings are updated, then previously captured rates can be re-normalised without re-scraping the booking engine.
Implementation notes

The same mapping-table approach already used for source features (FeaturesMapGoogle.ts and the Booking and TripAdvisor maps), applied per IBE vendor; the Breakfast tag in DirectPricesDetail.vue is the only normalised commercial attribute today.

Edge cases & risks

Direct and OTA normalisation must share one vocabulary or the parity comparison in DARI-21 compares two different taxonomies and produces confident nonsense; cancellation terms also vary by market and by date, so a single classification per plan is already a simplification.

DARI-17

Return an occupancy and length-of-stay matrix

Proposed
Revenue managerShould
As a Revenue manager, I want rates across several occupancies and stay lengths at once, so that I can see the shape of the pricing grid instead of re-querying one combination at a time.
Acceptance criteria
  1. Given a hotel and a date, when I request the matrix, then rates are returned for each requested occupancy and each stay length in one response.
  2. Given the matrix renders, when a combination is unavailable, then the cell states why — no availability, minimum stay not met, or capture failed — rather than being blank.
  3. Given the matrix, when it renders, then per-person and per-room pricing are distinguished so double occupancy is not misread.
  4. Given a large matrix is requested, when it is submitted, then it is processed as a batch with progress reporting rather than a blocking call.
  5. Given a matrix request would exceed the hotel's configured capture budget, when it is submitted, then it is refused with the limit stated.
Implementation notes

Generalises the existing getDirectPricingPrices(huId, {startDate, interval, nights, guests}) parameters — which already accept nights and guests — from single values into ranges, reusing the { batchId } polling model for execution.

Edge cases & risks

The combinatorial explosion is real: seven stay lengths by four occupancies by 365 days is over ten thousand captures per hotel, so this only works with strict budgets and sensible defaults, not as an always-on feature.

DARI-18

Capture promotional codes and member rates

Proposed
Hotel ownerShould
As a Hotel owner, I want the platform to capture my promotional and member rates as well as my public rate, so that the direct rate we compare against OTAs is the one my guests actually get.
Acceptance criteria
  1. Given a configured promotional code, when a capture runs, then the rate returned with that code is captured alongside the public rate and labelled with the code.
  2. Given a member or loyalty rate is exposed by the booking engine, when it is captured, then it is flagged as a member rate with the programme name where available.
  3. Given a code has expired or been withdrawn, when the capture runs, then the failure is recorded against the code rather than reported as a price drop or a capture error.
  4. Given member and promotional rates exist, when a parity comparison runs, then the comparison basis — public only, or best available including codes — is explicit.
  5. Given codes are stored, when they are handled, then they are treated as organisation-scoped configuration rather than being exposed in shared exports by default.
Implementation notes

Extends the capture configuration per hotel and mirrors the member-rate detection proposed for metasearch in META-20, so both sides of the parity comparison speak the same language.

Edge cases & risks

Promotional codes are commercially sensitive and sometimes contractually restricted, so storing and replaying them needs the same consent basis as rate shopping itself; automated code use can also trip booking-engine abuse protection.

DARI-19

Detect stay restrictions such as CTA, CTD and minimum LOS

Proposed
Revenue managerMust
As a Revenue manager, I want to see closed-to-arrival, closed-to-departure and minimum-stay restrictions per date, so that I understand why a date is unbookable rather than assuming it is sold out.
Acceptance criteria
  1. Given a date with a restriction, when the calendar renders, then the restriction type is shown on the row rather than the date simply reading "No availability".
  2. Given a minimum length of stay applies, when I select a shorter stay, then the row states the minimum rather than returning no rate.
  3. Given restrictions are captured, when they are returned by the API, then they are separate fields from availability so consumers can distinguish "closed" from "empty".
  4. Given a restriction cannot be determined from the booking engine, when the row renders, then it is marked unknown rather than assumed absent.
  5. Given restrictions are present, when derived statistics are computed, then restricted dates are excluded from average-rate calculations or the exclusion is stated.
Implementation notes

Adds restriction fields to the per-day payload of GET /direct-pricing/prices/:huId and to the row rendering in DirectPricesDetail.vue; the landing page already promises "LOS rules" as part of the Direct ARI product, so the claim precedes the surface.

Edge cases & risks

Restrictions are often inferred rather than published — a rate disappearing at a given stay length is evidence, not a declaration — so inference confidence needs to be exposed or the calendar will assert rules the hotel never set.

DARI-20

Schedule refreshes and control cadence per hotel

Proposed
Organisation adminMust
As an Organisation admin, I want to set how often each hotel is refreshed, so that I can spend capture budget on the properties and dates that matter.
Acceptance criteria
  1. Given an enabled hotel, when I configure its cadence, then I can set the refresh frequency and the lookahead window it applies to.
  2. Given near dates matter more, when I configure cadence, then I can set a shorter interval for the near window and a longer one for the far window.
  3. Given a schedule runs, when it completes, then the cost in captures is recorded and visible against the organisation's budget.
  4. Given a configured cadence would exceed the organisation's allowance, when I save it, then I am warned with the projected consumption before it takes effect.
  5. Given a hotel is disabled or scheduled to disable, when the next window arrives, then no captures occur for it.
Implementation notes

The scrapers package already provides the execution model — PeriodicTaskRunner.schedule(task, interval, name), staleTimeThresholdMs, batchLimit = 100, concurrency = 50 and getStaleHotels — so this is primarily per-hotel configuration and budget accounting rather than new machinery.

Edge cases & risks

Cadence is the main cost lever in the product and it is currently invisible, so exposing it will reveal that the effective refresh rate differs between customers; the landing page advertises "Refresh cadence" as a product attribute without stating a number, which makes the first published figure a commitment.

DARI-21

Show direct rates beside OTA rates in one parity view

Proposed
Hotel ownerMust
As a Hotel owner, I want my direct rate and every OTA rate on one row per date, so that I can answer "where am I being undercut this weekend, by which OTA, and by how much?" in one screen.
Acceptance criteria
  1. Given a hotel with both direct pricing and metasearch enabled, when the parity view loads, then each date row shows the direct rate and each advertiser's rate side by side.
  2. Given a comparison, when it renders, then only like-for-like combinations are compared on normalised board basis, occupancy, stay length and refundability, and non-comparable cells are marked rather than compared.
  3. Given an advertiser undercuts the direct rate, when the cell renders, then it is highlighted with the absolute and percentage gap.
  4. Given capture timestamps differ between the direct and OTA sides, when the row renders, then both timestamps are shown so the comparison's currency is visible.
  5. Given the hotel has no direct pricing enabled, when I open the view, then I am offered the enable path rather than shown an empty comparison.
Implementation notes

Joins GET /direct-pricing/prices/:huId with GET /metasearch, reusing the heat-gradient, weekend-shading and ±% gap-colouring vocabulary already implemented in views/CompetitorPriceMatrix.vue; this is the direct realisation of the landing promise "see exactly how your rates compare — and against the hotel's own direct rate".

Edge cases & risks

This is the flagship claim of the product and the hardest to do honestly: without META-19, META-23 and DARI-16 the two sides are not measuring the same thing, and a wrong parity call is the kind of error a hotel takes to its OTA account manager.

DARI-22

Report coverage and reliability by booking-engine vendor

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want coverage and success rates broken down by booking-engine vendor, so that I can prioritise the integrations that unlock the most hotels.
Acceptance criteria
  1. Given the linked estate, when the report is generated, then it shows hotels linked, hotels enabled, capture success rate and median capture latency per IBE vendor.
  2. Given a vendor's success rate drops below a threshold, when the report runs, then it is flagged so the regression is visible before customers report it.
  3. Given unlinked hotels, when the report is generated, then they are grouped by the vendor detected on the hotel's direct website so integration work can be prioritised by reach.
  4. Given the report is produced, when it is viewed, then it is scoped internally by the same ubio-access check as the other internal screens.
  5. Given a vendor has too few hotels for a meaningful rate, when the report renders, then the figure is suppressed rather than shown as 0% or 100%.
Implementation notes

Builds on getDirectPricingIbes() and the per-day capture statuses already stored; the scrapingStatuses collection is the established precedent for per-source status tracking with {status, error, updatedAt} shapes, and hasDirectWebsite already exists as a search filter for detecting direct websites.

Edge cases & risks

Vendor attribution depends on correctly identifying the engine behind a hotel's website, which is itself a detection problem, so early versions of this report will misattribute a share of hotels and skew prioritisation.

DARI-23

Surface actionable capture diagnostics to the customer

Proposed
Hotel ownerMust
As a Hotel owner, I want to know why my rates did not update in language I can act on, so that I can fix my side or escalate with the right information.
Acceptance criteria
  1. Given a failed capture, when I view the date, then the reason is expressed in customer terms — for example "the booking engine did not respond", "no rates published for this date" or "the point-of-sale link is no longer valid" — with an internal code retained for support.
  2. Given a range with widespread failures, when the calendar renders, then a range-level banner summarises the problem and its scope rather than requiring row-by-row inspection.
  3. Given a failure is caused by a broken link, when it is detected, then the remedial action is offered inline, such as re-verifying the point-of-sale URL.
  4. Given a failure is transient, when the next capture succeeds, then the banner clears automatically.
  5. Given a failure persists beyond a threshold, when the threshold is crossed, then the account is notified rather than the state remaining silent on a screen nobody has open.
Implementation notes

Maps the internal reasons already carried by the per-day Status column in DirectPricesDetail.vue onto a customer-facing catalogue, following the platform's existing pattern of a single sanitised customer message with detail retained internally, as in the export failure copy "Export failed. Please try again later or contact support if the problem persists."

Edge cases & risks

Over-sanitising produces the current export-style message that tells the customer nothing; under-sanitising leaks internal scraping details and vendor names. The catalogue has to be curated per failure class, which is ongoing work rather than a one-off mapping.

DARI-24

Emit a webhook when a direct rate changes

Proposed
DeveloperShould
As a Developer, I want to be pushed a notification when a hotel's direct rate changes, so that my system reacts without polling a 365-day calendar.
Acceptance criteria
  1. Given a registered webhook endpoint, when a capture produces a changed rate or availability for a subscribed hotel, then a signed payload is delivered describing the hotel, date, stay length, old and new values and the capture timestamp.
  2. Given delivery fails, when retries are attempted, then they follow a documented backoff and the failures are visible to the customer.
  3. Given an endpoint is failing persistently, when the threshold is reached, then the subscription is suspended and the customer is notified rather than retries continuing indefinitely.
  4. Given a subscription is created, when it is configured, then the customer can scope it by list, by hotel and by minimum change magnitude to control volume.
  5. Given a payload is delivered, when it is verified, then a signature allows the receiver to confirm it came from Hotel Universe.
Implementation notes

There is no outbound webhook capability in the API clone — only inbound Stripe at POST /billing/webhook — even though the landing flow section advertises "push diffs to a webhook", so this establishes the outbound delivery layer that list diffs would also use.

Edge cases & risks

Direct rate changes are high-frequency by nature, so without magnitude thresholds and scoping a single enabled list can generate a punishing event stream; signing, replay protection and per-customer rate limiting all belong in the first version rather than a follow-up.

DARI-25

Record rate-shopping permission and consent per hotel

Proposed
Organisation adminMust
As an Organisation admin, I want a record of who authorised us to capture a hotel's direct rates, so that we can demonstrate a lawful and contractual basis for the data we hold.
Acceptance criteria
  1. Given a hotel is linked for direct pricing, when the link is created, then a consent record is stored naming the basis, the party who granted it, the date and the scope.
  2. Given a consent record, when it is reviewed, then any restriction attached to it — such as no redistribution or no use in parity alerting — is enforced by the API rather than by convention.
  3. Given consent is withdrawn, when it is recorded, then captures stop for that hotel and the withdrawal is timestamped and auditable.
  4. Given a hotel has no consent record, when direct pricing is enabled for it, then the action is blocked or explicitly flagged for review depending on the configured policy.
  5. Given an export or a webhook would include consent-restricted data, when it is produced, then the restricted records are excluded and the exclusion is reported.
Implementation notes

Mirrors the pattern established by AccessManager.licensedSources, which already gates the source_data export type, but at hotel and consent level rather than at data-source level; it is a prerequisite for the self-serve linking proposed in DARI-15.

Edge cases & risks

Retrofitting consent to an already-linked estate is the hard part — every existing link needs a basis recorded or a decision to grandfather it — and enforcement must be server-side, because a UI-only restriction is not a control anyone would accept in a contractual review.

DARI-26

Retain direct rate history and expose a change log

Proposed
Revenue managerShould
As a Revenue manager, I want to see the history behind a date's price rather than just its last movement, so that I can understand how my own calendar has behaved over the booking window.
Acceptance criteria
  1. Given a date in the calendar, when I open its history, then every captured price and availability reading is listed with its timestamp.
  2. Given a history view, when it renders, then the room and rate plan for each reading is shown so a "price change" caused by a different lead-in room is distinguishable from a genuine rate move.
  3. Given failed captures, when history renders, then they appear as gaps with reasons rather than being omitted, so the series is not silently interpolated.
  4. Given a retention period, when it elapses, then older readings are aggregated or removed on a documented schedule.
  5. Given history exists, when I export it, then it is available in the same export formats as the other pricing datasets.
Implementation notes

The detail screen already displays a single previous price per day with an up or down arrow, which implies at least one prior reading is retained; this makes the series a queryable product surface, alongside the offer archive proposed in META-22 and the time-series precedent set by the accessLogs collection.

Edge cases & risks

Direct history is the evidence base for any parity dispute, so its retention period is a commercial decision as much as a storage one; aggregating too aggressively destroys exactly the granularity that makes it useful as evidence.

Part 8

Subscriptions, billing & usage

SUBS 9 built2 partial11 proposed

Rich content subscriptions & freshness

Rich content subscriptions are the commercial engine of the Hotel Details product: the landing page promises "Subscribe the list for 365 days of automatic updates" and closes with "Find. Subscribe. Stay fresh for 365 days." In the portal this shows up as a per-hotel entitlement (access.isRichContentSubscribed(huId)) bought in bulk from search or from a list, priced against a yearly richContent plan quota, and enforced everywhere a full profile, a record export or a list export is served. This epic covers the subscribe funnel that exists today — the "Subscribe to N selected" rail action, RichContentSubscribeModal.vue, POST /orgs/:orgId/subscribed-hotels, the Subscribed / Not subscribed tabs and the export gating — plus the lifecycle work that is entirely missing: renewal, expiry warnings, release, transfer, freshness guarantees and what happens to data when a subscription lapses.

SUBS-01

Subscribe selected hotels straight from search

Built
OTA supply managerMust
As an OTA supply manager, I want to subscribe the hotels I have just selected in search results, so that the properties I care about start receiving 365 days of automatic content updates without a detour through list management.
Acceptance criteria
  1. Given I am on /search on the paid tier and have ticked one or more result rows, when I open the selection rail, then a Subscribe to N selected action is shown with N matching hotelSelectionManager.getSelectedCount().
  2. Given I click Subscribe to N selected, when the action fires, then RichContentSubscribeModal.vue opens pre-loaded with the selected huIds rather than subscribing immediately.
  3. Given my org is on the trial or full tier, when the selection rail renders, then the subscribe action is not shown at all, because showRichContentSubscribeOnSearch(tier) is paid-only.
  4. Given I have zero rows selected, when the rail renders, then the subscribe action is absent or disabled rather than opening an empty modal.
  5. Given a virtual selection is active because totalResults > STATIC_SELECTION_LIMIT (1000), when I subscribe, then only the materialised ids (capped by MAX_VIRTUAL_PREVIEW_HOTELS = 250) are sent and I am told the selection was truncated.
Implementation notes

The rail lives in the HotelsSearch.vue selection sidebar alongside Save search as dynamic list and Compare metasearch; gating comes from app/utils/accessTier.tsshowRichContentSubscribeOnSearch(tier); ids come from HotelSelectionManager.getSelectedIds(), persisted in sessionStorage under hotel-universe:selected-hotels.

Edge cases & risks

Virtual select-all across a million-row result set is the obvious foot-gun: a user who thinks they subscribed "all results" may have subscribed 250 of them, or may blow through a yearly quota in one click. Selection state survives page reloads via sessionStorage, so a stale selection from an earlier filter can be subscribed by accident.

SUBS-02

See exactly what I am about to subscribe and what I already use

Built
Organisation adminMust
As an Organisation admin, I want the subscribe dialog to show how many hotels will be added and where that leaves my plan, so that I can approve a spend decision with the numbers in front of me.
Acceptance criteria
  1. Given the modal opens with a set of huIds, when it renders, then it shows a hotels-to-add label derived from formatHotelsToAddLabel counting only hotels not already subscribed.
  2. Given my org has a richContent subscription, when the modal renders, then it shows the plan tier line (formatTierLabel/prettifyPlan) and a usage line built from deriveRichContentUsage / prettifyRichContentUsage, e.g. included quota versus hotels already subscribed.
  3. Given every selected hotel is already subscribed, when the modal opens, then the hotels-to-add count is zero and the confirm button is disabled rather than posting an empty request.
  4. Given plan data has not loaded yet, when the modal opens, then usage lines show a loading state instead of rendering NaN or undefined.
Implementation notes

components/hotel/RichContentSubscribeModal.vue with the quota maths in app/utils/billingFormatters.ts (deriveRichContentUsage, prettifyRichContentUsage, formatHotelsToAddLabel); plan and usage come from api.getCurrentPlan()GET /billing/subscription{ tier, subscriptions, billing, usage }.

Edge cases & risks

Usage is a snapshot: two admins subscribing concurrently can each see "within quota" and jointly push the org over. The de-duplication of already-subscribed hotels depends on AccessManager's 30-second cached access info, so a hotel subscribed moments ago in another tab may still be counted as new.

SUBS-03

Warn me before a subscription takes me over my included quota

Built
Organisation adminMust
As an Organisation admin, I want an explicit charge warning when the hotels I am adding exceed my included allowance, so that I am never surprised by an overage line on the next invoice.
Acceptance criteria
  1. Given the hotels-to-add count would push subscribed hotels past the included quota, when the modal renders, then computeRichContentOverLimit returns a positive overage and the warning text from formatRichContentOverLimitChargeMessage is displayed with the number of extra hotels and the resulting charge.
  2. Given an over-limit state, when the confirm button renders, then its label is "Continue and subscribe" rather than "Subscribe", so the extra confirmation is visible in the button itself.
  3. Given the addition stays within the included quota, when the modal renders, then no warning is shown and the button reads "Subscribe".
  4. Given an over-limit state, when I look at the actions, then an upgrade path to Billing is offered alongside "Continue and subscribe" so I can move to a tier where the hotels are included.
  5. Given I dismiss the modal after seeing the warning, when it closes, then nothing is posted and my subscribed-hotel count is unchanged.
  6. Given the over-limit calculation cannot be performed because plan pricing is missing, when the modal renders, then it fails safe by showing a generic "additional charges may apply" note instead of quoting a wrong figure.
Implementation notes

computeRichContentOverLimit and formatRichContentOverLimitChargeMessage in app/utils/billingFormatters.ts, consumed by RichContentSubscribeModal.vue; the two-label button is the only friction between a click and a billable event, and the upgrade link is a plain navigation to Billing that drops the pending selection rather than deep-linking the richContent panel.

Edge cases & risks

The quoted charge is computed client-side from plan metadata; if the server prices overage differently the customer sees one number and is billed another. Losing the pending selection on the way to Billing is a real abandonment point — after upgrading the user must re-run the search, re-select and re-subscribe.

SUBS-04

Record the subscription against the organisation

Built
DeveloperMust
As a Developer, I want a single endpoint that adds hotels to my organisation's subscribed set, so that the same entitlement can be created from the portal, the API or an MCP-driven agent flow.
Acceptance criteria
  1. Given a set of huIds, when I POST /orgs/:orgId/subscribed-hotels, then the hotels are added to the organisation's subscribed set and the response reflects how many were newly added.
  2. Given some ids are already subscribed, when the call is made, then they are treated idempotently rather than double-charged.
  3. Given a huId that does not resolve to a canonical hotel, when the call is made, then it is rejected or reported as skipped, and no entitlement is created for it.
  4. Given I am not a member of :orgId, when the call is made, then it is refused with 403 and nothing is written.
  5. Given the write succeeds, when the portal refreshes, then access.refreshAccessInfo(true) returns the new subscribed set and the UI updates without a full reload.
Implementation notes

ApiManager.addSubscribedHotels(orgId, huIds)POST /orgs/:orgId/subscribed-hotels; the resulting entitlement is read back through AccessManager.currentAccessInfo and exposed as isRichContentSubscribed(huId).

Edge cases & risks

This endpoint is a billable write with no idempotency key in the client call — a double-submitted modal or a retried request over a flaky connection is the most likely source of a disputed overage charge. The endpoint does not exist in the local API clone, so its validation behaviour is only observable in production.

SUBS-05

Tell me at a glance which hotels are subscribed

Built
Data analystMust
As a Data analyst, I want subscribed hotels marked in search results and on the hotel profile, so that I know which properties I am actually receiving fresh content for before I plan an export.
Acceptance criteria
  1. Given a search result row for a subscribed hotel, when HotelResultsTable.vue renders the Name column, then a subscribed badge icon appears next to the hotel name.
  2. Given I open a subscribed hotel, when HotelDetailHero.vue renders, then a "Subscribed" badge is shown alongside any "Direct ARI available" badge.
  3. Given I am on the paid tier, when I use the richContentSubscribed filter chip ("Subscribed hotels"), then results are restricted to hotels my org has subscribed.
  4. Given I am on the trial or full tier, when I browse the filter catalogue, then isFilterDefAvailableForTier hides the richContentSubscribed filter.
  5. Given access info fails to load, when the results render, then no hotel is falsely badged as subscribed.
Implementation notes

Badge state comes from AccessManager.isRichContentSubscribed(huId) against currentAccessInfo; the filter is defined in app/components/search/filterDefs.ts as richContentSubscribed and gated by isFilterDefAvailableForTier.

Edge cases & risks

refreshAccessInfo has a 30-second TTL cache, so badges can lag a just-completed subscribe. On the full tier every hotel is effectively unlocked (hasUnrestrictedRichContentAccess), which makes the badge meaningless there and can confuse internal users testing the paid experience.

SUBS-06

Show me when my rich content access expires

Partial
Revenue managerMust
As a Revenue manager, I want to see when my 365 days of updates run out, so that I can renew before my catalogue silently goes stale.
Acceptance criteria
  1. Given my org has a richContent subscription, when I open a hotel profile, then HotelDetailRail.vue shows a "Rich content · subscribed" state with an expiry derived from access.richContentExpiresAt.
  2. Given the expiry is in the future, when the rail renders, then a remaining-days figure from access.richContentExpiresInDays() is shown.
  3. Given the expiry has passed, when the rail renders, then the state reverts to the subscribe CTA rather than claiming an active subscription.
  4. Given no richContent subscription exists, when the rail renders, then the quota line and expiry are omitted entirely.
Implementation notes

AccessManager exposes richContentExpiresAt and richContentExpiresInDays(); the only consumer today is the subscribe CTA + quota line block in HotelDetailRail.vue. The named gap: expiry is surfaced on one screen, per hotel profile, and nowhere in Lists, Billing or the top bar — see SUBS-13 and SUBS-14.

Edge cases & risks

A day count computed client-side against the browser clock will disagree with the server for users in other time zones or with a skewed clock, which matters most on the last day. If the org holds subscriptions bought at different times, a single org-level richContentExpiresAt cannot represent them honestly.

SUBS-07

Split a list into subscribed and unsubscribed hotels

Built
OTA product managerMust
As an OTA product manager, I want my list to separate hotels I am subscribed to from hotels I am not, so that I can see the gap between the supply I care about and the supply I am actually paying to keep fresh.
Acceptance criteria
  1. Given my org is on the paid tier, when I open a list, then ListView.vue renders Subscribed and Not subscribed tabs in addition to Needs review, Rejected/Unmatched and Confirmed Hotels.
  2. Given the tabs render, when stats load, then each tab badge shows subscribedHotels / notSubscribedHotels from listStatsManager.
  3. Given my org is on trial or full, when I open a list, then those two tabs are absent because usesRichContentSubscriptionTabs(tier) is paid-only.
  4. Given I deep-link to ?tab=subscribed on a non-paid tier, when the route resolves, then listViewValidRouteTabs(tier) rejects the tab and I land on a valid one instead of an empty screen.
  5. Given the list is still processing, when I open a subscription tab, then counts show as pending rather than zero.
Implementation notes

ListView.vue tab logic plus app/utils/accessTier.ts (usesRichContentSubscriptionTabs, listViewValidRouteTabs); counts come from ListStatsManager (subscribedHotels, notSubscribedHotels) fed by GET /lists/:id/stats, and entries are fetched with getSavedListEntries(listId, page, richContent).

Edge cases & risks

For dynamic lists the membership changes underneath the tabs, so a hotel can move from Not subscribed to out-of-list between page loads. Very large lists paginate the entries but the counts come from a separate call, so the two can briefly disagree.

SUBS-08

Bulk subscribe the unsubscribed hotels in a list

Built
Travel agency consultantMust
As a Travel agency consultant, I want to subscribe everything on the Not subscribed tab in one action, so that a hotel programme I have just built becomes fully maintained without ticking hundreds of rows.
Acceptance criteria
  1. Given I am on the Not subscribed tab of a list, when I select rows, then a bulk "Subscribe" bar appears with the selected count.
  2. Given I confirm, when the subscribe modal completes, then the selected hotels move to the Subscribed tab and both tab badges update.
  3. Given the selection exceeds my included quota, when the modal opens, then the same computeRichContentOverLimit warning and "Continue and subscribe" label apply as in search.
  4. Given the request partially fails, when the response returns, then a toast reports how many were subscribed and how many failed, and the tab reflects only the successes.
Implementation notes

The bulk bar sits on the Not-subscribed tab in ListView.vue, reuses RichContentSubscribeModal.vue and api.addSubscribedHotels, and refreshes via listStatsManager.load(listId) plus access.refreshAccessInfo(true).

Edge cases & risks

There is no "select every unsubscribed hotel in this list" affordance distinct from page-level selection, so on a 10,000-hotel list this becomes a paging grind — or, if it is wired to a select-all, an accidental five-figure spend. Concurrency with another admin doing the same thing produces double-counted quota until the next refresh.

SUBS-09

Show me a locked preview and tell me how to unlock it

Built
ProspectMust
As a Prospect, I want an unsubscribed hotel to render as a clearly locked preview rather than a broken page, so that I can see what the full record would contain and understand exactly what subscribing buys.
Acceptance criteria
  1. Given a hotel my org has not subscribed, when GET /hotels/:id returns { access: 'preview' }, then HotelDetails.vue renders the preview tab from HotelPreview data converted by utils/hotelPreviewToHotel.ts.
  2. Given a preview profile, when the tab strip renders, then overview, rooms, photos and reviews show a padlock and are not selectable.
  3. Given I hover a locked tab, when the tooltip appears, then it shows RICH_CONTENT_SUBSCRIBE_MESSAGE from app/copy/toastTexts.ts.
  4. Given a preview profile, when the right rail renders, then the rich-content subscribe CTA is shown with the current quota line.
  5. Given the directwebsite tab, when the profile is a preview, then the tab is hidden entirely rather than shown locked.
Implementation notes

HotelProfileAccess.FULL vs PREVIEW is resolved by HotelDetailsManager.loadHotel(id); the locked-tab treatment and the single shared copy constant RICH_CONTENT_SUBSCRIBE_MESSAGE are the only place this message is defined.

Edge cases & risks

Preview locking and trial 402 locking are two different mechanisms that produce similar-looking screens (UpgradeRequired versus padlocked tabs), which makes support diagnosis harder. A preview record that happens to be sparse looks like a data-quality problem rather than a paywall.

SUBS-10

Only let subscribed hotels be exported on the paid model

Partial
Customer data engineerMust
As a Customer data engineer, I want export actions to be honest about which hotels I am entitled to extract, so that I do not build a pipeline around a file that silently omits half my list.
Acceptance criteria
  1. Given my org is on the paid tier (usesPaidExportSubscriptionModel(tier)), when I open the Export split menu on the Needs review or Rejected/Unmatched tab, then export is disabled with the tooltip "Only subscribed hotels can be exported".
  2. Given I am on the Confirmed Hotels or Not subscribed tab, when I open the Export menu, then full, source_data and reviews are disabled with "Subscribe to hotels to unlock export" while preview remains available.
  3. Given I am on the Subscribed tab, when I export, then POST /lists/:id/exports runs for the chosen type and the resulting file contains only subscribed hotels.
  4. Given source_data is chosen, when access.licensedSources.length === 0, then it stays disabled with the source-data addon tooltip regardless of subscription state.
  5. Given a subscription expires between export creation and download, when I click download, then the entitlement is re-checked rather than serving a file built under an expired subscription.
Implementation notes

Gating lives in ListHeader.vue's export split menu against app/utils/accessTier.ts; export creation is ListExportsManager.exportListHotels(listId, type)POST /lists/:id/exports with types preview | full | source_data | reviews. The named gap is criterion 5: the export record carries expiresAt for the file (30 days) and a 15-minute signed URL, but nothing re-checks rich-content entitlement at download time.

Edge cases & risks

A dynamic list re-resolved after export can contain hotels that were never subscribed; the file is a point-in-time artefact with no manifest of which entitlement produced it. Trial orgs have a separate, unrelated 100-hotel export cap, so two different limits can truncate the same file for different reasons.

SUBS-11

Block the single-record export for unsubscribed hotels

Built
Hotel ownerShould
As a Hotel owner, I want the "export this record" action to refuse politely when the property is not subscribed, so that I understand it is an entitlement issue rather than a broken download.
Acceptance criteria
  1. Given a subscribed hotel, when I click export-record in the right rail, then the profile is downloaded as <huId>.json.
  2. Given an unsubscribed hotel, when I click export-record, then no file is produced and a warning toast explains that a rich content subscription is required.
  3. Given the org is on the full tier, when I click export-record, then the download proceeds because hasUnrestrictedRichContentAccess(tier) bypasses the check.
  4. Given the profile is only a preview, when the file would be written, then the action is blocked rather than exporting a thin preview object labelled as a full record.
Implementation notes

HotelDetailRail.vue's export-record action, gated on access.isRichContentSubscribed(huId) with the warn toast; the payload is the loaded Hotel object from HotelDetailsManager.

Edge cases & risks

This is a purely client-side gate on data the client already holds — anyone with dev tools can read the same object from the network response, so it is a UX guardrail rather than an enforcement boundary. The check is per-hotel and does not consider whether the subscription is within its 365-day window.

SUBS-12

Auto-renew a rich content subscription

Proposed
Organisation adminMust
As an Organisation admin, I want my per-hotel subscriptions to renew automatically at the end of the 365 days, so that the "stay fresh" promise does not quietly break on an anniversary date nobody was watching.
Acceptance criteria
  1. Given an org with a richContent subscription, when I open Billing, then an auto-renew toggle is shown per product with its current state and next renewal date.
  2. Given auto-renew is on and a hotel's 365-day window ends, when the renewal job runs, then the entitlement is extended for another 365 days and an invoice line is raised.
  3. Given auto-renew is on but the renewal charge fails, when the payment attempt is exhausted, then the entitlement enters the grace state (SUBS-22) and the admin is notified rather than the hotel silently locking.
  4. Given auto-renew is off, when the window ends, then the entitlement lapses and the hotel returns to preview access on the next refreshAccessInfo.
  5. Given the renewal would exceed the plan's included quota because the tier was downgraded mid-year, when the job runs, then it renews up to the quota and flags the excess for the admin instead of silently charging overage.
Implementation notes

Builds on the existing yearly richContent Stripe product and richContentExpiresAt; would need a scheduled renewal task keyed on entitlement expiry plus a customer.subscription.updated handler that reconciles quota against the current tier.

Edge cases & risks

Auto-renew on a per-hotel entitlement with a yearly plan is easy to get wrong: renewing hotels the customer no longer cares about is the classic source of billing disputes, so it needs a pre-renewal statement (SUBS-14) and an easy release path (SUBS-15).

SUBS-13

Warn me at 30, 7 and 1 days before expiry

Proposed
Revenue managerMust
As a Revenue manager, I want to be warned as my subscription window closes, so that I can renew or release hotels before my rates and content stop updating.
Acceptance criteria
  1. Given richContentExpiresInDays() returns 30, 7 or 1, when I load any authenticated portal screen, then a dismissible banner appears naming the expiry date and the number of hotels affected.
  2. Given the same thresholds, when the daily notification job runs, then an email is sent to organisation admins with a link to Billing.
  3. Given I dismiss the 30-day banner, when I return the next day, then it does not reappear until the next threshold is crossed.
  4. Given auto-renew is enabled, when a threshold is crossed, then the message is a renewal notice rather than an expiry warning.
  5. Given expiry has already passed, when I load the portal, then the banner switches to a lapsed-state message with the grace-period end date.
Implementation notes

Client side this rides on the values AccessManager already computes; server side it needs the first non-invitation email type in the platform — today sendInvitationEmail is the only SendGrid template that exists.

Edge cases & risks

With a single org-level richContentExpiresAt the banner cannot say "these 40 hotels expire on Tuesday and these 300 in March" — per-entitlement expiry dates are a prerequisite. Banner fatigue is real; three thresholds plus the metasearch allowance banner can stack on the same screen.

SUBS-14

Give me a subscription expiry dashboard

Proposed
Organisation adminShould
As an Organisation admin, I want one screen listing every subscribed hotel with its expiry date and renewal state, so that I can plan a renewal budget instead of discovering lapses hotel by hotel.
Acceptance criteria
  1. Given subscribed hotels, when I open the subscriptions dashboard under Settings, then I see a table of hotel name, huId, subscribed-on date, expires-on date, days remaining and renewal state.
  2. Given the table, when I sort by days remaining or filter by "expiring in 30 days", then the view updates without a full reload.
  3. Given I select rows, when I choose an action, then I can renew, release (SUBS-15) or enable auto-renew for just those hotels.
  4. Given more than one page of hotels, when I scroll, then paging follows the existing infinite-scroll pattern used by ListEntries.vue.
  5. Given no subscribed hotels, when the screen loads, then an EmptyState explains the subscribe flow instead of an empty grid.
Implementation notes

A new /subscriptions route in the SettingsLayout sub-shell alongside Billing and Usage, backed by a GET /orgs/:orgId/subscribed-hotels read counterpart to the existing POST, joined to preview data via the same HotelPreview shape search already returns.

Edge cases & risks

For an org with tens of thousands of subscribed hotels this becomes a reporting screen, not a list — it needs server-side aggregation for the "expiring soon" counts rather than paging the whole set. Hotel names change over time, so the table must render current canonical names, not names captured at subscribe time.

SUBS-15

Release a hotel and reclaim the seat

Proposed
OTA supply managerMust
As an OTA supply manager, I want to unsubscribe hotels I no longer track and get the quota back, so that a supply file that churns every quarter does not force me to keep buying tier upgrades.
Acceptance criteria
  1. Given a subscribed hotel, when I choose "Release subscription" from the hotel rail or the expiry dashboard, then a confirmation dialog explains that the hotel reverts to preview access.
  2. Given I confirm, when the release completes, then the org's used quota decreases and the freed seat is immediately usable for another hotel.
  3. Given a release policy window (e.g. seats released within N days of purchase are refunded to quota, later releases are not), when I release, then the dialog states which case applies before I confirm.
  4. Given the released hotel appears in a list, when I return to the list, then it moves from the Subscribed to the Not subscribed tab.
  5. Given I attempt to release a hotel that is not subscribed, when the call is made, then it is a no-op rather than corrupting the quota count.
Implementation notes

Requires DELETE /orgs/:orgId/subscribed-hotels mirroring the existing POST, plus a policy decision on whether released seats return to quota, and a recompute of deriveRichContentUsage.

Edge cases & risks

Freely reclaimable seats invite rotation abuse — subscribing a hotel, exporting the full profile, then releasing it — so release almost certainly needs a minimum hold period or an export-linked lock. Releasing after export also raises the question of whether previously exported data may still be used.

SUBS-16

Transfer a subscription from one hotel to another

Proposed
Customer data engineerCould
As a Customer data engineer, I want to move a subscription seat from a hotel that turned out to be the wrong match to the correct one, so that an entity-resolution mistake does not cost me a second seat.
Acceptance criteria
  1. Given a subscribed hotel, when I choose "Transfer subscription" and pick a target huId, then the source entitlement ends and the target inherits the remaining days.
  2. Given the target hotel is already subscribed, when I attempt the transfer, then it is refused with a clear message and no seats are consumed.
  3. Given the transfer completes, when I look at the target, then its expiry equals the source's original richContentExpiresAt, not a fresh 365 days.
  4. Given a transfer allowance (e.g. N transfers per subscription year), when the allowance is exhausted, then further transfers are refused with the count shown.
  5. Given the source hotel was exported under the subscription, when I transfer, then the transfer is still permitted but recorded in the audit trail.
Implementation notes

Sits naturally next to the Needs-review match queue in ListReviewQueue.vue, where wrong-candidate approvals are exactly the failure this fixes; implemented as an atomic release-and-add against the subscribed-hotels store carrying the original expiry.

Edge cases & risks

Transfers are an abuse vector for the same reason release is — a rolling transfer chain gives unlimited coverage for one seat. Duplicate canonical records (the same property under two huIds) are the legitimate case and should probably be handled by a merge, not a transfer.

SUBS-17

Guarantee a refresh cadence per subscribed hotel

Proposed
OTA product managerShould
As an OTA product manager, I want a stated freshness SLA and a visible "last refreshed" timestamp for every subscribed hotel, so that I can hold the "365 days of automatic updates" promise to an actual number.
Acceptance criteria
  1. Given a subscribed hotel, when I open its profile, then the rail shows "Last refreshed <date>" per data group (info, images, rooms, rating, features) alongside the subscription state.
  2. Given a hotel has not been refreshed within its plan's SLA window, when the profile renders, then an "overdue" indicator is shown with the age.
  3. Given the subscriptions dashboard, when I filter by "overdue refresh", then I get every subscribed hotel breaching the SLA.
  4. Given a hotel whose sources are permanently unreachable, when it is marked as un-refreshable, then it is reported as such rather than counting silently as overdue forever.
  5. Given an SLA breach, when the monthly usage report is produced, then the breach count is included.
Implementation notes

The raw material already exists: canonicalData6.updatedAt, per-field provenance in _dsNames, and scrapingStatuses with per-source {status, error, updatedAt}; the missing pieces are exposing them on the profile and defining a per-tier cadence (the landing page already advertises "Refresh cadence" as a product attribute).

Edge cases & risks

updatedAt moves when any source writes, so a hotel can look fresh while its Booking rooms data is months old — the SLA has to be per field group or it will be misleading. Committing to a cadence turns scraper reliability into a contractual obligation.

SUBS-18

Put subscribed hotels at the front of the scraping queue

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want the scrapers to prioritise hotels that customers have subscribed, so that the properties we are paid to keep fresh are refreshed before the long tail of the 1M+ index.
Acceptance criteria
  1. Given the stale-hotel selection runs, when candidates are ranked, then subscribed hotels are ordered ahead of unsubscribed hotels within the same batch.
  2. Given a subscribed hotel exceeds its SLA window, when the next scheduling cycle runs, then it is picked even if staleTimeThresholdMs (30 days) has not elapsed.
  3. Given no subscribed hotels are due, when the cycle runs, then the batch falls back to the existing staleness ordering so throughput is not wasted.
  4. Given a subscribed hotel repeatedly errors, when it has failed N consecutive times, then it is escalated to an ops queue rather than consuming a priority slot forever.
  5. Given priority scheduling is enabled, when ops inspect the run, then the batch composition (priority vs backfill) is observable.
Implementation notes

AbstractScrapingService.getStaleHotels currently selects on status and staleTimeThresholdMs only, with batchLimit = 100 and concurrency = 50; this adds a subscribed-hotel join (or a denormalised isSubscribed flag on scrapingStatuses) into the work-selection query.

Edge cases & risks

Priority slots are finite: if subscription volume grows past scraping capacity the SLA (SUBS-17) breaks for everyone at once, so this needs capacity headroom monitoring. Joining a fast-growing entitlement set into the hot work-selection query is a scale concern in its own right.

SUBS-19

Notify me when a subscribed hotel's content changes

Proposed
Customer data engineerShould
As a Customer data engineer, I want change notifications limited to the hotels I subscribe to, so that I can sync only what moved instead of re-exporting a full catalogue every week.
Acceptance criteria
  1. Given a subscribed hotel, when a canonical field group changes materially, then a change event is recorded with the hotel id, the field groups affected and a timestamp.
  2. Given I have configured a webhook, when change events accumulate, then they are delivered as a batch with signature and retry, matching the landing promise "push diffs to a webhook".
  3. Given a hotel I have not subscribed changes, when events are generated, then nothing is delivered to me.
  4. Given delivery fails repeatedly, when the retry budget is exhausted, then events are retained for pull via an API endpoint and the failure is surfaced in the portal.
  5. Given a subscription lapses, when the hotel changes afterwards, then no further events are delivered for it.
Implementation notes

Would build on the merge path in HotelDataUpdateService.combineData, which already knows which FIELD_GROUPS a source write touched and which provenance changed; delivery is genuinely new — the only webhook in the system today is inbound Stripe.

Edge cases & risks

"Material change" needs a definition or every re-scrape emits noise (an image URL re-encryption or a review count tick would fire constantly). Outbound webhooks to customer endpoints introduce an SSRF and secret-management surface the platform does not currently have.

SUBS-20

Auto-subscribe new hotels that join a list

Proposed
Travel agency consultantShould
As a Travel agency consultant, I want hotels that newly match my dynamic list to be subscribed automatically, so that a programme defined as a search stays fully maintained without me checking the diff every month.
Acceptance criteria
  1. Given a dynamic list, when I enable "auto-subscribe new matches", then a per-list setting is stored alongside its searchQuery.
  2. Given the list is refreshed and GET /lists/:id/diff returns new hotels, when auto-subscribe is on, then those hotels are subscribed and the admin is notified with the count and any charge.
  3. Given auto-subscribe would exceed the included quota, when the diff runs, then it stops at the quota and reports the shortfall rather than silently incurring overage.
  4. Given a per-run cap is configured, when the diff exceeds it, then the run subscribes up to the cap and flags the remainder for manual review.
  5. Given auto-subscribe is off, when the diff runs, then behaviour is unchanged and new hotels appear only in ListDiff.vue.
Implementation notes

Hooks the existing ListDiffManager.load(listId) / addToList(listId, huIds) path and POST /orgs/:orgId/subscribed-hotels; the setting belongs with the dynamic list record that already carries searchQuery.

Edge cases & risks

A broad saved search (for example a whole country) can match thousands of new hotels after an ingest run, so an uncapped auto-subscribe is a runaway-spend hazard. Search recall also changes as the index grows, so the diff is not always a real-world change.

SUBS-21

Forecast where my subscription usage is heading

Proposed
Ubio finance opsCould
As a Ubio finance ops analyst, I want a forecast of subscribed-hotel growth per organisation, so that I can predict overage revenue and flag accounts that should be moved to a higher tier before they get an unpleasant invoice.
Acceptance criteria
  1. Given at least three months of subscription history for an org, when the forecast runs, then it projects subscribed-hotel count to the end of the plan year with a confidence band.
  2. Given the projection crosses the included quota, when the forecast is reviewed, then the org is flagged with the projected crossing date and the expected overage.
  3. Given an org with fewer than three months of history, when the forecast runs, then it reports "insufficient history" rather than extrapolating from two points.
  4. Given a flagged org, when a sales rep opens the account view, then the forecast and the recommended tier are shown.
  5. Given the forecast is regenerated, when inputs have not changed, then the previous output is reused rather than recomputed.
Implementation notes

Feeds off the same subscribed-hotel history the expiry dashboard needs plus lists:create and hotels:view volumes in accessLogs; surfaces in an internal view gated the same way Usage.vue gates all-org access on @ub.io email.

Edge cases & risks

Subscription growth is lumpy — one bulk subscribe of a supply file dwarfs months of organic growth — so naive linear projection will mislead. Anything shown to a sales rep as "expected overage" needs a caveat or it becomes a promise.

SUBS-22

Define a grace period and what happens to data after expiry

Proposed
Corporate travel buyerMust
As a Corporate travel buyer, I want a written, enforced policy on what happens when a subscription lapses, so that my procurement team knows whether we lose access, lose data, or keep what we already exported.
Acceptance criteria
  1. Given a subscription expires, when the grace period (e.g. 14 days) begins, then hotels remain fully accessible and the portal shows a lapsed banner with the grace end date.
  2. Given the grace period ends without renewal, when I open a previously subscribed hotel, then it reverts to preview access and locked tabs, exactly as an unsubscribed hotel.
  3. Given the grace period ends, when I attempt a full, source_data or reviews export, then it is refused with the same "Subscribe to hotels to unlock export" gating.
  4. Given files exported while the subscription was active, when the subscription lapses, then existing export records remain downloadable until their own expiresAt (30 days) but no new exports are produced.
  5. Given I renew during the grace period, when the payment settles, then access is restored with no re-subscribe step and no loss of the subscribed-hotel set.
Implementation notes

Extends AccessManager's entitlement resolution with a lapsed | grace | active state rather than a boolean, and reuses the existing export expiresAt (30 days) and 15-minute signed-URL mechanics for previously created files.

Edge cases & risks

The retention question is legally loaded: content already delivered to the customer cannot be recalled, so the policy must distinguish continued access from continued use rights. A grace period that silently keeps serving data also weakens the renewal incentive if it is too long or repeatable.

BILL 8 built3 partial11 proposed

Plans, billing & subscription management

Hotel Universe sells three products on separate meters — smartFeed and metasearch monthly, richContent yearly — across five plan tiers (experience, consume, deliver, grow, more), with the landing page promising "Four self-serve tiers per product… Higher volumes and custom terms are handled by sales" and "Scale up or down any time". The portal's Billing screen is the whole self-serve surface: BillingCurrentPlan.vue, BillingPlans.vue and BillingInvoices.vue on top of Stripe checkout, change-tier, cancel, reactivate and the customer portal. This epic covers that surface, the webhook path that keeps access tiers in sync (with its swallowed errors, full-collection-scan org lookup and the unresolved legacy pro|pro_plus|ultra gating TODO), and the finance capabilities an enterprise buyer expects but cannot get today.

BILL-01

See what I am currently paying for, per product

Built
Organisation adminMust
As an Organisation admin, I want one card per product showing my tier, usage and renewal date, so that I can answer "what are we on and what does it cost" without opening Stripe.
Acceptance criteria
  1. Given my org has subscriptions, when I open /billing, then BillingCurrentPlan.vue renders a card per active product (smartFeed, metasearch, richContent) with a tier tag from formatTierLabel.
  2. Given a product card, when it renders, then it shows a usage line (formatSubscriptionUsageLine), an overage line (formatSubscriptionOverageLine) and, for metasearch, a prepaid line (formatMetasearchPrepaidLine).
  3. Given a subscription that renews, when the card renders, then it shows "Renews <date>" from formatRenewalDate.
  4. Given a subscription cancelled but not yet ended, when the card renders, then it shows "Cancels on <date> — access continues until then" together with a Keep button.
  5. Given my org is on trial or has full internal access, when the screen loads, then the header label reads Trial or Full Access respectively instead of implying a paid subscription.
  6. Given GET /billing/subscription fails, when the screen loads, then an error state is shown rather than an empty card grid implying no subscriptions.
Implementation notes

Billing.vueBillingCurrentPlan.vue, fed by ApiManager.getCurrentPlan()GET /billing/subscription{ tier, subscriptions, billing, usage }; all display strings come from app/utils/billingFormatters.ts.

Edge cases & risks

Usage and overage lines are formatted from whatever the API returns, so a missing usage block silently renders a thinner card rather than an explicit "usage unavailable". The trial | paid | full access tier and the per-product plan tier are two different axes shown on one screen, which is a persistent source of support confusion.

BILL-02

Compare tiers and pick one, or reach sales

Built
ProspectMust
As a Prospect, I want to see every tier for each product with prices and what is included, so that I can self-serve the right plan or start a conversation when my volumes are beyond the published tiers.
Acceptance criteria
  1. Given I open the Plans view, when BillingPlans.vue renders, then each product has a panel of plan cards priced $X.XX/mo. for smartFeed/metasearch and $X.XX/yr. for richContent.
  2. Given a plan card, when it renders, then it lists what is included as "N included" plus the overage price beyond it.
  3. Given the tier I am on, when the cards render, then that card carries a Current tag and its button reads Current Tier and is inert.
  4. Given a tier above or below mine in TIER_ORDER (experience, consume, deliver, grow, more), when the card renders, then the button reads Upgrade to X or Downgrade to X accordingly; for a product I have no subscription for it reads Select X.
  5. Given a sales-only tier, when the card renders, then the button reads Contact Us and opens mailto:hello@ubio.ai?subject=<Product> Plan.
  6. Given GET /billing/plans?product= fails for one product, when the view renders, then that panel shows an error while the other products still render.
Implementation notes

BillingPlans.vue with api.getPlans(product)GET /billing/plans?product=; button copy and ordering derive from PlanTier / TIER_ORDER and the SALES_MAILTO constant also used by the landing page's "Talk to sales".

Edge cases & risks

Prices are rendered in whatever currency the API returns via formatCurrency with no multi-currency handling (BILL-15). A mailto is the entire enterprise funnel — there is no lead capture, so an unsent email is an invisible lost deal.

BILL-03

Buy a product I do not have yet

Built
Organisation adminMust
As an Organisation admin, I want to check out a new product through Stripe, so that I can start using metasearch or rich content the same day without talking to anyone.
Acceptance criteria
  1. Given a product with no active subscription, when I click Select <tier>, then POST /billing/checkout is called and I am redirected to the returned checkoutUrl.
  2. Given the Stripe session, when it is created, then it is mode: subscription, card-only, and carries organizationId in metadata so the webhook can attribute it.
  3. Given I complete payment, when Stripe redirects to ${FRONTEND_URL}/billing?success=true, then the success path runs and the current-plan cards reload.
  4. Given I abandon checkout, when Stripe redirects with ?canceled=true, then I return to Billing with no subscription created and no error toast implying failure.
  5. Given checkout creation fails, when the call returns an error, then a toast explains it and I stay on Billing rather than being sent to a blank tab.
Implementation notes

ApiManager.createCheckout(...)POST /billing/checkout; the local clone's version takes legacy planName pro|pro_plus|ultra while the live portal passes product + tier — a divergence tracked in BILL-11. Success/cancel URLs are built from FRONTEND_URL.

Edge cases & risks

Entitlement is granted by webhook, not by the redirect, so a webhook delay leaves a paying customer looking at a trial screen. ?organizationId= on the return URL can auto-switch org (BILL-07), which means a checkout completed for org A can land the user in org A even if they started in org B.

BILL-04

Move up or down a tier mid-cycle

Built
Organisation adminMust
As an Organisation admin, I want to change tier on an existing product and have the money worked out fairly, so that the landing promise "Scale up or down any time" holds in practice.
Acceptance criteria
  1. Given an active subscription for a product, when I click Upgrade to X or Downgrade to X, then POST /billing/change-tier is called with the product and target tier.
  2. Given the change succeeds, when the response returns, then the current-plan card shows the new tier without a page reload and subscription_upgrade_started is tracked.
  3. Given an upgrade, when the change is applied, then Stripe prorates the remainder of the period so I am charged the difference rather than a full new period.
  4. Given a downgrade that would put my current usage above the target tier's included quota, when I confirm, then I am warned about the resulting overage before the change is applied.
  5. Given the change fails at Stripe, when the error returns, then the previous tier remains in force and the failure is surfaced rather than swallowed.
Implementation notes

ApiManager.changeTier(...)POST /billing/change-tier; upgrade/downgrade direction comes from the position in TIER_ORDER; the portal tracks subscription_upgrade_started via portalTracking.

Edge cases & risks

Criterion 4 is the dangerous one for richContent: subscribed hotels do not shrink when the tier does, so a downgrade can leave an org permanently over quota. Proration on a yearly product mid-year produces invoice lines customers routinely query.

BILL-05

Cancel a subscription, and change my mind

Built
Organisation adminMust
As an Organisation admin, I want to cancel a product and keep access until the period I have paid for ends, so that stopping a subscription is not the same as losing data mid-month.
Acceptance criteria
  1. Given an active subscription, when I click Cancel and confirm the $confirm.require dialog, then POST /billing/cancel is called for that product and subscription_cancellation_scheduled is tracked.
  2. Given the cancellation is scheduled, when the card re-renders, then it reads "Cancels on <date> — access continues until then" rather than removing access immediately.
  3. Given a scheduled cancellation, when I click Keep subscription, then POST /billing/reactivate restores it and the card returns to "Renews <date>", tracking subscription_reactivated.
  4. Given the period end passes without reactivation, when the customer.subscription.deleted webhook arrives, then the org's access tier drops and gated routes start redirecting to Billing.
  5. Given cancel or reactivate fails, when the error returns, then the card state is unchanged and a toast explains the failure.
Implementation notes

ApiManager.cancelSubscription(...) / reactivateSubscription(product)POST /billing/cancel and POST /billing/reactivate, rendered by the Cancel / Keep buttons on BillingCurrentPlan.vue; the confirm dialog is the shared global <ConfirmDialog> from AppLayout.vue.

Edge cases & risks

Cancelling richContent interacts badly with per-hotel entitlements — it is not obvious to the user whether the 365-day windows they already bought survive the plan cancellation (see SUBS-22). There is no exit survey or save offer, so churn reasons are never captured.

BILL-06

Manage cards and tax details in Stripe

Built
Ubio finance opsMust
As a Ubio finance ops analyst, I want customers to self-serve payment methods and billing details in Stripe's own portal, so that we never handle card data and finance is not fielding "please update our VAT number" tickets.
Acceptance criteria
  1. Given an org with a Stripe customer, when the user clicks "Manage in Stripe", then POST /billing/management returns a portalUrl and the browser navigates to it.
  2. Given the portal session, when it is created, then return_url is ${FRONTEND_URL}/billing so the user comes back to the same screen.
  3. Given the user opens the portal, when the action fires, then billing_portal_opened is tracked.
  4. Given an org with no Stripe customer yet, when the button is clicked, then it is disabled or returns a clear message rather than a Stripe error page.
Implementation notes

ApiManager.createCustomerPortalSession()POST /billing/management, using stripe.billingPortal.sessions.create with the configured return URL.

Edge cases & risks

Everything the customer changes in the Stripe portal — payment method, cancellation, tax id — comes back only as a webhook, so any event type the handler ignores leaves the portal out of sync with reality until the next getCurrentPlan().

BILL-07

Come back from Stripe into the right organisation

Built
Organisation adminShould
As an Organisation admin who belongs to several organisations, I want a Stripe return link to put me back in the org the payment was for, so that I do not update the wrong tenant's plan.
Acceptance criteria
  1. Given I return from Stripe to /billing?success=true, when handleStripeRedirect() runs, then the plan and invoice data are refetched and the query flags are cleared from the URL.
  2. Given the return URL carries ?organizationId=, when applyOrganizationFromQuery runs and I am a member of that org, then the portal switches to it before rendering Billing.
  3. Given ?organizationId= names an org I am not a member of, when the switch is attempted, then it is refused (403 on POST /auth/switch-org) and I stay in my current org with an explanatory message.
  4. Given the org switch succeeds, when it completes, then the new token is stored and dependent state (access info, AI assistant session) is reset for the new tenant.
  5. Given ?canceled=true, when the redirect is handled, then no refetch storm occurs and no success toast is shown.
Implementation notes

Billing.vue's handleStripeRedirect() and applyOrganizationFromQuery, which route through ApiManager.switchOrganization(orgId)POST /auth/switch-org; org switching elsewhere in the portal triggers window.location.reload().

Edge cases & risks

An auto-switching deep link is a genuine tenant-safety hazard: a link forwarded to a colleague silently moves them between organisations. The switch also resets AiAssistantManager state and reloads the page, so an in-flight action in the previous org is lost.

BILL-08

Find and download my invoices

Built
Ubio finance opsMust
As a Ubio finance ops analyst, I want customers to find their own invoices grouped by billing cadence, so that the monthly products and the yearly rich content charges reconcile without a support ticket.
Acceptance criteria
  1. Given I open the Invoices view, when BillingInvoices.vue renders, then a product-group select offers monthly and richContent and a month select narrows the period.
  2. Given a group and month, when invoices load via GET /billing/invoices?group=, then the table shows Date, Description, Products (with per-product lines), a Status tag, Amount, and a link to the Stripe-hosted invoice.
  3. Given no invoices for the chosen group and month, when the table renders, then an empty message is shown rather than a blank table.
  4. Given an invoice link, when I click it, then the Stripe invoiceUrl opens in a new tab.
  5. Given the invoices call fails, when the view renders, then an error state with a retry is shown.
Implementation notes

ApiManager.listInvoices(group?)GET /billing/invoices?group=monthly|richContent; the clone's implementation is stripe.invoices.list({ limit: 10 }) mapped to {id, date, description, status, amount, currency, invoiceUrl}.

Edge cases & risks

A hard limit: 10 cannot support a month selector across a full year — pagination or a date-filtered Stripe query is required or older invoices are simply unreachable. Amounts are rendered with formatCurrency from the invoice currency, so a customer billed in more than one currency sees an unaggregatable mix.

BILL-09

Keep access tiers in sync with Stripe reliably

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the Stripe webhook handler to be observable, resilient and efficient, so that a customer who has paid is never left on a trial screen and a failure is never invisible.
Acceptance criteria
  1. Given checkout.session.completed, when the webhook is processed, then the org named in metadata.organizationId has its billing record and access tier updated.
  2. Given customer.subscription.updated or customer.subscription.deleted, when processed, then the org's tier is recomputed and gated routes react on the next refreshAccessInfo.
  3. Given invoice.payment_failed, when processed, then the failure is recorded and surfaced to the org's admins rather than only logged.
  4. Given the handler throws, when the response is returned, then the error is recorded and the event is allowed to be retried by Stripe instead of being acknowledged with a 200 that hides the failure.
  5. Given a webhook for a customer id, when the org is resolved, then it is found by an indexed lookup on billing.stripeCustomerId rather than a full collection scan.
  6. Given an unhandled event type, when it arrives, then it is counted in metrics so gaps in coverage are visible.
Implementation notes

POST /billing/webhook (unauthenticated, verified by stripe-signature) handles exactly four event types; today all errors are swallowed and the endpoint always returns 200, and the org lookup is findAll().find(...) — a full scan of organizations. Tier mapping is simply status === 'active' → FULL.

Edge cases & risks

Swallowing errors plus always-200 means Stripe never retries, so a transient Mongo blip permanently loses an entitlement change with no alert. The full-collection scan is fine at hundreds of orgs and a problem at tens of thousands, and it runs on every webhook including high-frequency invoice events.

BILL-10

Make paid plans actually grant different access

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the access model to reflect which plan and tier an organisation bought, so that we stop selling five tiers that all unlock the same thing.
Acceptance criteria
  1. Given an org on any of the legacy plans pro, pro_plus or ultra, when access is computed, then it currently resolves to the same FULL entitlement — the behaviour this story replaces.
  2. Given the per-product model, when access is computed, then smartFeed, metasearch and richContent entitlements are independent and a metasearch subscription does not unlock rich content.
  3. Given a plan tier within a product, when quotas are applied, then the included allowance and overage terms of that tier are enforced server-side, not only displayed in the UI.
  4. Given an org with no subscription for a product, when it calls that product's endpoints, then it receives the appropriate 402 rather than being served because its overall tier is FULL.
  5. Given the migration, when a legacy-plan org is converted, then its effective access does not silently shrink without notice.
Implementation notes

The clone carries an explicit code TODO — "Decide whether / how much to gate based on tier / subscription plan" — with PlanName = pro | pro_plus | ultra mapped to STRIPE_PRICE_PRO|PRO_PLUS|ULTRA and a binary status === 'active' → FULL tier mapping; the live portal already models BillingProduct × PlanTier and AccessTier = trial | paid | full, so the gap is server-side enforcement catching up with the sold model.

Edge cases & risks

FULL is currently a super-tier that bypasses rich-content subscription gating entirely (hasUnrestrictedRichContentAccess), so tightening it will change behaviour for existing paying customers. Any tightening must be preceded by an audit of which orgs are relying on the accidental grant.

BILL-11

Top up a metasearch allowance without changing plan

Partial
Revenue managerMust
As a Revenue manager, I want to buy more metasearch queries when I run out mid-month, so that a busy pricing week does not force me onto a permanently larger plan.
Acceptance criteria
  1. Given my metasearch allowance is exhausted, when a priced call is made, then the API returns 402 with reason metasearch_topup_required and the portal distinguishes it from a plain upgrade-required 402.
  2. Given the top-up 402, when the screen renders, then the warning banner "You've reached your metasearch allowance…" is shown with a "View billing" button on Price Insights, Market Insights and the price matrix.
  3. Given I am approaching the allowance, when metasearchAllowance is returned with remaining volume, then the remaining amount is visible before I hit zero.
  4. Given I top up, when the purchase completes, then the prepaid line on the metasearch card (formatMetasearchPrepaidLine) reflects the new balance and the banners clear.
  5. Given a top-up purchase fails, when I return to the screen, then the exhausted banner is still shown and no allowance has been granted.
Implementation notes

METASEARCH_TOPUP_REQUIRED_REASON = 'metasearch_topup_required' splits the 402 branch in the portal; HotelDetailsManager carries priceInsightsTopUpRequired, metasearchAllowance and metasearchAllowanceExhausted. The named gap: the portal detects and explains the state, but the actual top-up purchase is a "View billing" hand-off rather than an in-place buy flow.

Edge cases & risks

Exhaustion is discovered at the moment of use, in the middle of an analysis, which is the worst possible time; criterion 3 depends on the API returning remaining allowance, which not every endpoint does. A prepaid balance and a monthly included allowance are two counters that must not double-decrement.

BILL-12

Report metered usage to Stripe

Proposed
Ubio finance opsMust
As a Ubio finance ops analyst, I want actual usage pushed to Stripe as metered quantities, so that overage lines on invoices are produced by the billing system rather than reconciled by hand.
Acceptance criteria
  1. Given a billing period, when usage is aggregated per product from accessLogs, then the quantities are reported to Stripe against the subscription's metered price.
  2. Given an org over its included quota, when the invoice is generated, then the overage line comes from the reported meter and matches the overage line the portal displayed.
  3. Given a reporting run fails, when it is retried, then quantities are idempotent per period so nothing is double-billed.
  4. Given a mid-period tier change, when usage is reported, then it is attributed to the correct subscription item on each side of the proration boundary.
  5. Given a discrepancy between internal counts and Stripe's, when the reconciliation job runs, then it is reported to finance rather than silently accepted.
Implementation notes

The clone explicitly has "no usage metering reported to Stripe" — quotas are enforced internally from access logs. This story wires the existing GET /stats aggregation (distinct hotels per action) into Stripe metered prices, which also requires deciding the billable unit per product.

Edge cases & risks

The current meter is distinct hotels per action per period, which is not a naturally additive quantity — reporting it as an increment will over- or under-count depending on window. Retroactive access-log writes (for example a delayed export completion) can land after a period closes.

BILL-13

Choose annual or monthly billing with a discount

Proposed
Corporate travel buyerShould
As a Corporate travel buyer, I want to switch a product between monthly and annual billing and see the annual saving, so that I can commit for a year in exchange for a better rate without a sales call.
Acceptance criteria
  1. Given the Plans view, when I toggle Monthly/Annual, then every card re-prices and shows the percentage saved on annual.
  2. Given I select an annual plan, when checkout completes, then the subscription is created on the annual price and the current-plan card shows "Renews <date>" a year out.
  3. Given an existing monthly subscription, when I switch to annual mid-cycle, then the proration is previewed before I confirm.
  4. Given richContent, which is already yearly, when the toggle renders, then it is not offered a monthly option rather than showing a broken price.
  5. Given the annual price is not configured for a tier, when the toggle is set to annual, then that card shows Contact Us instead of a wrong price.
Implementation notes

Extends BillingPlans.vue's per-product panels and GET /billing/plans?product= to return both cadences, reusing the existing changeTier proration path for cadence switches.

Edge cases & risks

Mixing cadences across products complicates the invoice grouping that BillingInvoices.vue already splits into monthly and richContent — the grouping becomes cadence-by-product rather than a fixed pair. Annual commitments also interact with quota resets, which must not reset monthly on an annual plan.

BILL-14

Pay by purchase order and invoice

Proposed
Corporate travel buyerMust
As a Corporate travel buyer, I want to be invoiced against a purchase order on net terms, so that our procurement process can approve Hotel Universe at all — card-only checkout is a hard blocker.
Acceptance criteria
  1. Given an enterprise agreement, when the account is configured for invoice billing, then Stripe checkout is replaced by an invoice-based subscription with net terms.
  2. Given a PO number and billing contact are recorded on the org, when an invoice is generated, then the PO number appears on it.
  3. Given invoice billing, when the Billing screen renders, then card-specific actions are hidden and an "Invoiced — contact your account manager" state is shown.
  4. Given an invoice is overdue beyond the agreed terms, when the dunning policy triggers, then access is suspended according to the contract rather than immediately at first failure.
  5. Given a self-serve org, when it tries to select invoice billing, then it is directed to mailto:hello@ubio.ai because the path is sales-gated.
Implementation notes

Stripe already supports send_invoice collection with days_until_due; the platform work is storing PO/billing-contact fields on organizations.billing and branching BillingCurrentPlan.vue on collection method.

Edge cases & risks

Net terms mean access is granted before payment, so entitlement and payment state diverge — the tier mapping (status === 'active') must handle past_due and unpaid deliberately. Manual PO handling also creates a class of orgs where self-serve tier changes must be blocked.

BILL-15

Bill in the customer's currency and handle tax correctly

Proposed
Ubio finance opsMust
As a Ubio finance ops analyst, I want prices, invoices and tax to reflect the customer's country, so that a London-based company selling to EU and US customers issues compliant invoices.
Acceptance criteria
  1. Given a customer in a supported region, when they view Plans, then prices render in their currency with the correct symbol via formatCurrency.
  2. Given a UK or EU customer, when an invoice is issued, then VAT is calculated and shown as a separate line with the applicable rate.
  3. Given a business customer with a valid VAT number, when reverse charge applies, then the invoice reflects it and no VAT is charged.
  4. Given an invalid VAT number, when it is submitted, then validation fails with a clear message and the previous tax status is retained.
  5. Given a customer changes billing country, when the next invoice is issued, then the new tax treatment applies from that period forward and historical invoices are unchanged.
Implementation notes

Stripe Tax plus per-currency prices; the portal side is mostly display — formatCurrency already takes a currency from the API, and BillingInvoices.vue would gain a tax column.

Edge cases & risks

Multi-currency breaks any cross-org revenue aggregation that assumes one currency, including the usage forecasting in SUBS-21. Currency is effectively fixed at subscription creation, so a customer who relocates needs a migration path rather than a toggle.

BILL-16

Set budget alerts and hard spend caps

Proposed
Organisation adminMust
As an Organisation admin, I want to set a monthly spend threshold that warns me and a cap that stops overage entirely, so that one enthusiastic analyst cannot generate a five-figure invoice.
Acceptance criteria
  1. Given I set a soft budget, when projected spend crosses it, then an in-portal banner and an email are sent to org admins naming the products responsible.
  2. Given I set a hard cap, when the cap is reached, then further overage-incurring actions (bulk subscribe, metasearch queries beyond the allowance) are refused with a 402 explaining the cap, not the plan.
  3. Given a hard cap is in force, when a non-billable action is taken (browsing already-subscribed hotels, downloading an existing export), then it still works.
  4. Given the cap is raised, when the change is saved, then blocked actions become available immediately without a re-login.
  5. Given no cap is configured, when spend grows, then behaviour is unchanged from today.
Implementation notes

Builds on the same quota-check point that already emits 402s (ACCESS_TIER_TRIAL_LIMIT, metasearch allowance) plus the overage maths in computeRichContentOverLimit; caps belong on organizations.billing and need to be enforced server-side, not only in the subscribe modal.

Edge cases & risks

A hard cap that stops a running batch mid-way — a bulk subscribe or a list-wide direct-pricing enable — leaves partial state that must be reported clearly. Caps also create an outage class that looks like a bug to end users unless the message is explicit.

BILL-17

Tell me what an action will cost before I take it

Proposed
Data analystShould
As a Data analyst, I want a cost estimate on any action that can incur charges, so that I can decide between a 200-hotel and a 2,000-hotel run before I click, not after.
Acceptance criteria
  1. Given a bulk subscribe, list export or a metasearch comparison across a list, when the confirmation appears, then it states the units consumed and the incremental charge, if any.
  2. Given the action is fully within included quota, when the estimate renders, then it says "included in your plan" with the remaining allowance after the action.
  3. Given the estimate cannot be computed, when the dialog renders, then it says so plainly rather than showing zero.
  4. Given a dynamic list whose size is an estimate, when the cost is shown, then it is presented as a range with the "~" convention already used for dynamic-list coverage.
  5. Given I proceed, when the action completes, then the actual units consumed are reported and any difference from the estimate is visible in Usage.
Implementation notes

Generalises the over-limit warning already implemented for rich content (formatRichContentOverLimitChargeMessage) into a shared estimator used by the export split menu in ListHeader.vue, the metasearch comparison entry points and the direct-pricing bulk enable dialogs.

Edge cases & risks

Estimates on dynamic lists are unavoidably wrong because membership is resolved at execution time — under-estimating is worse than not estimating, so the range must be conservative. Every new priced action then owes an estimator, which is ongoing maintenance.

BILL-18

Publish a pricing page with a calculator

Proposed
ProspectShould
As a Prospect, I want to see tiers and work out my likely cost before signing in, so that I can qualify Hotel Universe without creating an account or emailing sales.
Acceptance criteria
  1. Given I visit /pricing unauthenticated, when the page renders, then all four self-serve tiers per product are shown with prices and included volumes, matching what GET /billing/plans returns to signed-in users.
  2. Given the calculator, when I enter a number of hotels to subscribe and an expected monthly metasearch volume, then it shows the recommended tier per product and an estimated total.
  3. Given inputs beyond the published tiers, when the estimate is produced, then the page recommends "Talk to sales" with the mailto:hello@ubio.ai link.
  4. Given I am already signed in, when I open /pricing, then my current tiers are marked so the page doubles as an upgrade comparison.
  5. Given plan data cannot be loaded, when the page renders, then static fallback pricing is shown rather than an empty table.
Implementation notes

The landing already ships a pricing section with "Four self-serve tiers per product… Sign in to see all tiers" and a live /pricing route; this replaces the sign-in wall with real numbers sourced from the same plans endpoint the portal uses.

Edge cases & risks

Publishing prices removes discount flexibility and makes every enterprise negotiation start from a public anchor. Keeping a public page in sync with Stripe prices is a recurring drift risk if it is hand-maintained.

BILL-19

Request a trial extension

Proposed
Trial userCould
As a Trial user, I want to ask for more trial capacity when my evaluation is not finished, so that hitting the 100-hotel limit mid-proof-of-concept does not end the evaluation.
Acceptance criteria
  1. Given I hit the trial limit and see UpgradeRequired, when I choose "Request more trial access", then a short form captures my use case and submits a request.
  2. Given a request is submitted, when it reaches Ubio, then it appears in an internal queue with the org, the usage to date from /stats and the requested extension.
  3. Given a request is approved, when the extension is applied, then ACCESS_TIER_TRIAL_LIMIT is overridden for that org and the 402s stop without a re-login.
  4. Given a request is declined or expires, when I return to a gated screen, then the standard upgrade messaging is shown and I cannot submit an unlimited number of repeat requests.
  5. Given an org has already had an extension, when it requests another, then the request is flagged for sales rather than auto-approved.
Implementation notes

ACCESS_TIER_TRIAL_LIMIT is a single global env default today, so this needs a per-org override on organizations; the request surface can reuse the support-escalation path already built for the AI assistant (POST /ai/assistant/contact).

Edge cases & risks

Per-org limit overrides become an untracked pricing surface unless they carry an expiry and an owner. Trial hotel views are counted all-time, so an extension has to raise the ceiling rather than reset the counter.

BILL-20

Chase failed payments before access is lost

Proposed
Ubio finance opsMust
As a Ubio finance ops analyst, I want an automated dunning sequence and card-expiry reminders, so that we do not lose a paying customer to an expired card nobody noticed.
Acceptance criteria
  1. Given invoice.payment_failed, when the webhook is processed, then the org's admins receive an email with a link to update the payment method in the Stripe portal.
  2. Given repeated failures, when the dunning schedule advances, then reminders are sent on a defined cadence and the portal shows a persistent payment-problem banner.
  3. Given a card expiring within 30 days, when the reminder job runs, then admins are notified before the next charge attempt.
  4. Given payment succeeds after a failure, when the success event arrives, then all banners and scheduled reminders are cleared.
  5. Given the dunning window ends unpaid, when the final step runs, then access is downgraded in line with the published policy and the customer is told it has happened.
Implementation notes

invoice.payment_failed is already one of the four handled webhook events but has no customer-facing consequence; this requires the second email template in the platform (SendGrid currently sends only sendInvitationEmail) and depends on BILL-09 making webhook failures visible.

Edge cases & risks

Emails go to org members with no role model — every member is equally an "admin" today, so a dunning email may go to analysts rather than a billing owner. Silent downgrades interact with richContent entitlements that customers believe they have paid a year for.

BILL-21

Issue credits and refunds

Proposed
Ubio support agentShould
As a Ubio support agent, I want to apply a credit or refund against an organisation, so that I can resolve a billing complaint — a double-submitted bulk subscribe, say — without escalating to finance every time.
Acceptance criteria
  1. Given a support case, when I apply a credit with an amount and a reason, then it is recorded against the org and applied to the next invoice.
  2. Given a refundable charge, when I issue a refund, then it is processed through Stripe and reflected in the invoice list with a refunded status.
  3. Given a rich content overage caused by a duplicate subscribe, when I reverse it, then the affected hotel entitlements are corrected as well as the money.
  4. Given a credit above a threshold, when I try to apply it, then approval from finance is required before it takes effect.
  5. Given any credit or refund, when it is applied, then it is written to an audit trail with the actor, amount, reason and timestamp.
Implementation notes

Stripe credit notes and refunds via an internal-only surface, gated the same way other internal screens are (meta.requiresUbioAccess / isUbioUser() on @ub.io or ubio.ai email).

Edge cases & risks

Reversing an entitlement is not symmetrical with reversing a charge — data already exported cannot be recalled, so a refund policy needs to state what the customer may keep. The email-domain check is the only authorisation boundary today, which is thin for a money-moving action.

BILL-22

Support partner and reseller billing hierarchies

Proposed
Ubio sales repCould
As a Ubio sales rep, I want a parent account that pays for several child organisations, so that an agency network or a reseller can onboard their clients without each one holding a card.
Acceptance criteria
  1. Given a parent organisation, when child orgs are linked to it, then their usage rolls up to the parent's plan and quotas.
  2. Given a linked child, when its members open Billing, then they see plan and usage in read-only form with an explanation that billing is handled by the parent.
  3. Given the parent, when it opens Billing, then per-child usage and overage are broken out on the invoice and in Usage.
  4. Given a child is unlinked, when the change takes effect, then it reverts to its own trial or its own subscription with no loss of lists or subscribed hotels.
  5. Given a child would push the parent over quota, when the action runs, then the parent's budget cap (BILL-16) applies to the child.
Implementation notes

organizations has no hierarchy today — membership is a flat memberIds[] and billing is per-org — so this needs a parent reference plus quota resolution that walks it, alongside the per-child breakdown in GET /stats (which already accepts an orgId).

Edge cases & risks

Cross-org data visibility is the hard part: a parent that can see a child's usage must not thereby see the child's lists or hotel data. The internal @ub.io all-orgs bypass on GET /orgs and /stats is the only precedent for cross-org reads, and it is not a model to generalise from.

USGE 5 built5 partial11 proposed

Usage, quotas, metering & reporting

Every billable interaction in Hotel Universe leaves a row in accessLogs, a MongoDB time-series collection with a one-year TTL holding {time, principalId, huId, meta:{orgId, action}} for actions like hotels:view, metasearch:view, lists:create and the export:create: / export:download: families. That single stream is simultaneously the product's analytics, its quota enforcement (trial orgs are cut off at ACCESS_TIER_TRIAL_LIMIT distinct hotels viewed, counted all-time) and the only evidence finance has of what a customer actually consumed. This epic covers the logging, the GET /stats aggregation behind the Usage screen, the tier gates and allowance banners that surface exhaustion, the portal's separate POST /portal-events telemetry stream, and the reporting, alerting and rolling-window work needed before usage-based pricing can be trusted.

USGE-01

Record every billable action

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want a single durable log of every entitlement-consuming action, so that quotas, invoices and support investigations all read from the same source of truth.
Acceptance criteria
  1. Given a request that consumes entitlement, when it completes, then a row is appended to accessLogs with {time, principalId, huId, meta:{orgId, action}}.
  2. Given the supported actions, when logging occurs, then the action is one of hotels:view, reviews:view, metasearch:view, lists:create, export:create:{full,preview,reviews} or export:download:{full,preview,reviews}.
  3. Given an export of N hotels, when it is created, then one access-log row per huId is written so distinct-hotel counting works on exports too.
  4. Given a list creation, when the list is still processing, then logging waits for terminal status (polled every second, up to ten minutes) before recording lists:create.
  5. Given a row older than one year, when the TTL runs, then it is removed automatically.
Implementation notes

AccessTracking writes to the accessLogs time-series collection (timeField: time, metaField: meta, hourly granularity, 1-year TTL); ExportService writes a row per huId, and listCreation polls list status before logging.

Edge cases & risks

The ten-minute polling window means a list that takes longer is never logged, so lists:create under-counts exactly the largest lists. Writing one row per hotel per export makes a single 100k-hotel export the dominant write in the collection, and the log is fire-and-forget so a failed write silently loses billable evidence.

USGE-02

Aggregate usage per organisation and period

Built
Data analystMust
As a Data analyst, I want an endpoint that returns distinct-hotel counts per action for a date range, so that I can report what my organisation consumed without access to raw logs.
Acceptance criteria
  1. Given orgId, startDate and endDate (ms epoch), when I call GET /stats, then I receive [{action, uniqueHotelCount}] for that organisation and window.
  2. Given actions with no activity in the window, when the response is built, then they are either omitted or returned as zero consistently, not both.
  3. Given I am not a member of the requested org, when I call /stats, then the request is refused — unless my email contains @ub.io, in which case the membership check is bypassed.
  4. Given an inverted or absent date range, when the call is made, then it is rejected with a validation error rather than scanning the whole collection.
Implementation notes

GET /stats/ aggregates distinct huId per meta.action over the time-series collection; the portal calls it via ApiManager.getOrganizationStats({organizationId, startDate, endDate}).

Edge cases & risks

The metric is distinct hotels, not number of calls, so viewing the same hotel a thousand times reports as one — useful for rich-content pricing, misleading for rate limiting or metasearch volume. Wide ranges over a busy org are an unbounded aggregation with no result cap.

USGE-03

Show me my usage in the portal

Built
Organisation adminMust
As an Organisation admin, I want a Usage screen with a date range and readable action names, so that I can check consumption before an invoice arrives.
Acceptance criteria
  1. Given I open /usage, when the screen loads, then start and end date pickers default to the last 30 days and stats load for that window.
  2. Given results, when UsageTable.vue renders, then each action code is mapped to a description (for example export:create:full → a human phrase) with a Unique hotels count column.
  3. Given no rows for the window, when the table renders, then the "No usage data found" empty state is shown.
  4. Given the stats call fails, when the screen renders, then an error state is shown rather than an empty table implying zero usage.
  5. Given I change either date, when the range is valid, then the table reloads for the new window.
Implementation notes

app/views/Usage.vue with components/.../UsageTable.vue, calling api.getOrganizationStats(...); the action-to-description map is hard-coded in the table component.

Edge cases & risks

The screen shows distinct-hotel counts only — there is no cost, no quota context and no per-user attribution, so it cannot answer "why is our bill this size". The action map is a hard-coded list, so any new logged action appears as a raw code until the portal is redeployed.

USGE-04

Let Ubio staff inspect any organisation's usage

Partial
Ubio support agentShould
As a Ubio support agent, I want to select any organisation on the Usage screen, so that I can investigate a billing query without asking the customer to send screenshots.
Acceptance criteria
  1. Given my email contains @ub.io, when /usage loads, then the org selector is populated from GET /orgs (all organisations) rather than only my own.
  2. Given a normal user, when /usage loads, then the selector lists only orgs from GET /users/me/orgs.
  3. Given I select another org, when stats load, then GET /stats accepts the request because ubio users bypass the membership check.
  4. Given the all-orgs list is large, when the selector renders, then it is searchable rather than an unfiltered dropdown of every organisation.
  5. Given the all-orgs call fails, when the screen loads, then it falls back to my own orgs rather than rendering an empty selector.
Implementation notes

Usage.vue branches on isUbee(user) and calls api.getAllOrganizations()GET /orgs (ubio-only); the same email-domain check drives AccessManager.isUbioUser() and the requiresUbioAccess route guard.

Edge cases & risks

Authorisation for cross-tenant usage data rests entirely on an email-domain substring check with no audit trail of which staff member viewed which org. Criterion 4 is the named gap: GET /orgs returns every organisation unpaginated, so the selector degrades as the customer base grows.

USGE-05

Stop trial organisations at the hotel-view limit

Partial
Trial userMust
As a Trial user, I want a clear, consistent message when I reach the trial's hotel limit, so that I understand I have hit a commercial boundary rather than a bug.
Acceptance criteria
  1. Given a trial org, when the number of distinct hotels viewed reaches ACCESS_TIER_TRIAL_LIMIT (default 100), then GET /hotels/:huId returns 402 "Upgrade to view. Contact sales at hello@ubio.ai".
  2. Given the 402, when the portal handles it, then HotelDetailsManager sets upgradeRequired and UpgradeRequired.vue renders with "View Billing" and "Contact Sales to Upgrade".
  3. Given a hotel I have already viewed, when I open it again, then it still loads because the counter is over distinct hotels.
  4. Given I am approaching the limit, when I view hotels, then I am warned before I am blocked — this does not happen today.
  5. Given my org upgrades, when I retry, then the 402 stops immediately without a re-login.
Implementation notes

AccessTierService counts distinct huId for hotels:view from accessLogs; the limit is the env var ACCESS_TIER_TRIAL_LIMIT, applied globally with no per-org override.

Edge cases & risks

The count is all-time, not per period, so a trial that has been open for a year is permanently exhausted and there is no reset — see USGE-16. There is no warning before the wall (criterion 4), so the first signal a prospect gets is a hard block in the middle of an evaluation.

USGE-06

Cap trial exports without blocking evaluation

Partial
Trial userShould
As a Trial user, I want to export a sample of my list on the trial plan, so that I can validate the schema in my own pipeline before paying.
Acceptance criteria
  1. Given a trial org, when I create a full or reviews export, then it is truncated to the first 100 hotels.
  2. Given a trial org, when I create a preview export, then it is not truncated.
  3. Given a list larger than the cap, when I open ListView.vue, then the trial banner states that exports are "limited to the first 100 hotels on the trial plan" before I create one.
  4. Given the truncated file, when it is produced, then the truncation is evident from the export record rather than only from a banner I may not have read.
  5. Given the org upgrades, when a new export is created, then it contains the full list.
Implementation notes

The trial export limit of 100 for full/reviews (preview unlimited) is applied in ExportService; the banner lives in ListView.vue and the export records themselves are listed by ListExports.vue with type, size and status.

Edge cases & risks

Criterion 4 is the gap: the export record carries no truncation flag, so a downstream pipeline sees a valid gzipped JSON array that is simply short — a silent data-loss mode. The 100-hotel export cap and the 100-hotel view cap are different counters that share a number, which makes support explanations confusing.

USGE-07

Gate metasearch and reviews to entitled tiers

Partial
Revenue managerMust
As a Revenue manager, I want price and review features to be unlocked by what my organisation has bought, so that I am not shown screens I cannot use or blocked from ones I have paid for.
Acceptance criteria
  1. Given a trial org, when GET /metasearch or GET /hotels/:huId/reviews is called, then a 402 is returned because allowsMetasearch / allowsReviews are FULL-only.
  2. Given a route with meta.requiresMetasearchAccess, when I navigate to it without access, then router.beforeEach redirects me to Billing rather than rendering an empty screen.
  3. Given an org with a paid metasearch subscription but not the FULL tier, when it uses metasearch, then access is granted — AccessManager.hasMetasearchAccess() already allows this in the portal.
  4. Given the same org, when it calls GET /metasearch directly over the API, then it is entitled by the same rule as the portal rather than by the FULL-only server check.
  5. Given reviews on a trial org, when a hotel profile renders, then the reviews tab and the AI review summary are hidden rather than shown and then erroring.
Implementation notes

Server-side allowsMetasearch/allowsReviews are FULL-only in the clone; the live portal's AccessManager.hasMetasearchAccess() accepts "full tier or a metasearch subscription", and hasDirectPricingRefreshAccess() remains full-tier-only with a code comment noting a planned paid add-on. The named gap is criterion 4 — the two definitions of entitlement need to converge (see BILL-10).

Edge cases & risks

Divergent client and server entitlement rules mean the portal can offer a button that the API refuses, or vice versa; PAT-driven and MCP clients only ever see the server rule. Route-level redirects to Billing also bounce users out of deep links without explaining why.

USGE-08

Warn when the metasearch allowance is spent

Built
Revenue managerMust
As a Revenue manager, I want the pricing screens to tell me when I have used my metasearch allowance, so that an empty chart is never mistaken for "no competitors found".
Acceptance criteria
  1. Given metasearchAllowance.exhausted, when Price Insights renders, then a warning banner "You've reached your metasearch allowance…" is shown with a "View billing" button.
  2. Given the same state, when Market Insights (AiDataInsights.vue) or the competitor price matrix renders, then the same banner and button appear.
  3. Given a 402 with reason metasearch_topup_required, when the portal handles it, then it shows the top-up message rather than the generic upgrade-required message.
  4. Given allowance remains, when the screens render, then no banner is shown and results load normally.
  5. Given the allowance is exhausted, when data is partially available from cache, then it is clearly labelled as previously fetched rather than presented as current.
Implementation notes

HotelDetailsManager carries metasearchAllowance, metasearchAllowanceExhausted, priceInsightsUpgradeRequired and priceInsightsTopUpRequired; the banners are rendered on HotelPriceInsights.vue, AiDataInsights.vue and CompetitorPriceMatrix.vue.

Edge cases & risks

These three screens each fetch different endpoints, so allowance state can be discovered at three different moments in one session. There is no pre-flight check, so a user can start a 12-month matrix run and be cut off part-way with a partially populated grid.

USGE-09

Stream portal telemetry for product analytics

Built
Ubio platform engineerShould
As a Ubio platform engineer, I want portal interactions batched to the backend, so that we can see which features are used without bolting on a third-party analytics vendor.
Acceptance criteria
  1. Given a tracked interaction, when portalTracking.track(event, pageOrMetadata?, metadata?) is called, then the event is buffered client-side.
  2. Given buffered events, when the 5-second flush interval fires, then they are sent as a batch to POST /portal-events.
  3. Given the page is being hidden or unloaded, when pagehide fires, then a final flush is sent with keepalive so the last events are not lost.
  4. Given a named authenticated route change, when router.afterEach runs, then page_view is tracked with the route name; public routes are not tracked.
  5. Given the flush request fails, when the next interval fires, then tracking continues without breaking the UI or growing the buffer without bound.
Implementation notes

PortalTrackingManager with roughly forty named events — from hotel_search_executed, search_filter_applied and list_csv_upload:id|details through subscription_upgrade_started, subscription_cancellation_scheduled and welcome_tour_completed — all posted to POST /portal-events.

Edge cases & risks

This is a second, entirely separate usage stream from accessLogs with different semantics and no defined retention, which makes "how much did they use" answerable two ways. Event names are free-form strings scattered across components, so a typo silently creates a new event rather than failing.

USGE-10

Rate limit the endpoints that cost money

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want per-principal rate limits on all expensive endpoints, so that one runaway script cannot exhaust upstream capacity or a customer's allowance in seconds.
Acceptance criteria
  1. Given repeated calls to GET /metasearch by one principal, when RATE_LIMIT_REQUESTS (default 100) is exceeded within RATE_LIMIT_DURATION_SECONDS (60), then 429 is returned with Retry-After.
  2. Given REDIS_URL is configured, when limits are counted, then they are shared across API instances; otherwise they are per-instance in memory.
  3. Given search, hotel-profile, export-creation and AI-assistant endpoints, when they are called at high volume, then they are also limited — today they are not.
  4. Given a PAT used by an automated agent, when it exceeds its limit, then the limit applies per principal so one agent cannot starve a colleague's token.
  5. Given a limit is hit, when the client retries after the advertised delay, then the request succeeds.
Implementation notes

RateLimitService uses rate-limiter-flexible keyed by principal, backed by Redis when configured; it is applied to GET /metasearch only. Criterion 3 is the named gap and is the main defence for the scraper-backed and AI endpoints.

Edge cases & risks

Without Redis the in-memory limiter multiplies the effective limit by the number of instances, so the configured number is not the real number. Unlimited export creation is the most expensive gap — each export streams a full list through GCS.

USGE-11

Show a live usage meter in the top bar

Proposed
Data analystShould
As a Data analyst, I want my remaining allowance visible while I work, so that I can pace a day of price research instead of discovering exhaustion mid-analysis.
Acceptance criteria
  1. Given an org with quotas, when any authenticated screen renders, then the top bar shows a compact meter with used/included for the most constrained product.
  2. Given I hover or click the meter, when the popover opens, then per-product usage, remaining allowance and the reset or renewal date are shown.
  3. Given a quota-consuming action completes, when the response returns updated usage, then the meter updates without a page reload.
  4. Given the org has unlimited access (FULL tier), when the meter renders, then it shows an unlimited state rather than a misleading full bar.
  5. Given usage data cannot be loaded, when the top bar renders, then the meter is hidden rather than showing zero.
Implementation notes

Follows the pattern already proven by the AI assistant's context meter (SVG ring plus popover with a segmented bar and legend) in AiAssistant.vue, fed by the usage block that GET /billing/subscription already returns and refreshed alongside AccessManager.refreshAccessInfo.

Edge cases & risks

A meter on every screen means a usage fetch on every navigation unless it piggybacks the existing 30-second access-info cache. Showing a number that lags reality by up to 30 seconds is worse than no number when the user is close to a hard cap.

USGE-12

Break usage down by user and by token

Proposed
Organisation adminMust
As an Organisation admin, I want to see which member or which access token consumed our quota, so that I can find the runaway integration instead of guessing.
Acceptance criteria
  1. Given a date range, when I open Usage, then I can group results by principal as well as by action.
  2. Given a principal that is a PAT, when it is displayed, then it is shown by token name (PAT <name>) rather than an opaque id.
  3. Given a member who has since been removed from the org, when their historical usage is shown, then it is attributed to their recorded identity rather than disappearing.
  4. Given a token that is deleted, when historical usage is shown, then the token name is preserved for the reporting period.
  5. Given grouping by principal, when the org has many members, then the table paginates and can be sorted by consumption.
Implementation notes

accessLogs already records principalId on every row and accessTokens records {name, createdBy, orgId}, so this is an aggregation and join rather than new instrumentation; it extends GET /stats with a grouping parameter and UsageTable.vue with a dimension selector.

Edge cases & risks

Principal identity is not immutable — deleting a token or removing a member breaks the join, so names must be denormalised at log time or resolved from a historical record. Per-user usage is also employee-monitoring data, which some customers will need to disable.

USGE-13

Warn at 50, 80 and 100 per cent of quota

Proposed
Organisation adminMust
As an Organisation admin, I want graduated warnings as we consume our allowance, so that hitting a limit is a scheduled decision rather than an outage.
Acceptance criteria
  1. Given usage crosses 50, 80 or 100 per cent of an included allowance, when the threshold job runs, then an in-portal notice and an email are raised once per threshold per period.
  2. Given the 100 per cent notice, when it is raised, then it states what happens next — overage charges, a hard stop, or a top-up requirement — for that specific product.
  3. Given a threshold already notified in the current period, when usage fluctuates around it, then no duplicate notification is sent.
  4. Given a new billing period, when quotas reset, then thresholds re-arm.
  5. Given a trial org approaching ACCESS_TIER_TRIAL_LIMIT, when 80 per cent is reached, then the same mechanism warns before the hard 402.
Implementation notes

Uses the same aggregation as GET /stats plus the included quantities returned by GET /billing/subscription; needs threshold state stored per org per period so notifications are idempotent, and a second SendGrid template (only sendInvitationEmail exists today).

Edge cases & risks

All-time trial counting (USGE-16) means a trial org can be at 80 per cent from day one, which makes the warning meaningless without a rolling window. Notification recipients are undefined while every org member has identical scopes.

USGE-14

Export usage reports and read them over the API

Proposed
Ubio finance opsShould
As a Ubio finance ops analyst, I want usage as a downloadable file and as an API resource, so that it can be reconciled against invoices in a spreadsheet or pulled into a customer's own finance system.
Acceptance criteria
  1. Given a date range on the Usage screen, when I click Export, then a CSV of action, principal, distinct hotels and call counts is downloaded.
  2. Given a PAT with the appropriate scope, when I call the usage API for my org, then I receive the same figures as the portal for the same window.
  3. Given a window longer than a supported maximum, when I request it, then the request is rejected with guidance rather than timing out.
  4. Given the export is generated asynchronously for a large window, when it is ready, then it follows the existing export pattern (status polling, signed download URL, expiry).
  5. Given a customer requests usage for an org they are not a member of, when the call is made, then it is refused.
Implementation notes

Reuses the existing export machinery — ExportService's stream-to-GCS pipeline, the exports collection with expiresAt and 15-minute signed URLs — over accessLogs aggregations rather than hotel data; the API surface is a documented extension of GET /stats.

Edge cases & risks

Usage data is more sensitive than hotel data because it reveals customer behaviour, so its exports need the same 30-day lifecycle and short-lived URLs and probably tighter scopes. Aggregating a year of a large org's time-series into a synchronous request is exactly the timeout case criterion 3 guards against.

USGE-15

Give finance a per-product usage dashboard

Proposed
Ubio finance opsShould
As a Ubio finance ops analyst, I want an internal dashboard of usage and overage per product across all organisations, so that I can forecast revenue and spot accounts on the wrong tier.
Acceptance criteria
  1. Given the internal dashboard, when it loads, then it shows per-product totals (smartFeed, metasearch, richContent) for the current period with month-on-month movement.
  2. Given an org list view, when it renders, then each org shows its tier, included quota, actual usage and projected overage.
  3. Given an org projected to exceed its tier, when the dashboard renders, then it is flagged for a tier conversation.
  4. Given the dashboard, when accessed by a non-Ubio user, then it is not routable at all.
  5. Given a period with incomplete data, when it renders, then it is marked as partial rather than shown as a decline.
Implementation notes

Internal-only route guarded like the existing meta.requiresUbioAccess screens; data comes from GET /stats per org (ubio users already bypass the membership check) joined to the plan data behind GET /billing/subscription, with charting reusing the chart.js setup already registered for HotelPriceInsights.vue.

Edge cases & risks

Running /stats per org across every customer is an N-query fan-out that will need a pre-aggregated rollup collection well before it is useful. The @ub.io email check remains the only authorisation boundary for a screen showing every customer's commercial behaviour.

USGE-16

Count quotas over a rolling window, not all time

Proposed
Trial userMust
As a Trial user, I want the trial allowance to be measured over a period rather than for ever, so that an evaluation that pauses for a quarter is not permanently dead.
Acceptance criteria
  1. Given a trial org, when distinct hotel views are counted, then only views within the configured window (for example the trailing 30 days or since trial start) are counted.
  2. Given the window rolls forward, when older views age out, then the org regains capacity without manual intervention.
  3. Given a paid product with a monthly allowance, when the billing period rolls over, then the counter resets in step with the invoice period, not the calendar month.
  4. Given the change is deployed, when existing trial orgs are recomputed, then no org that currently has access loses it as a side effect.
  5. Given the window is configured per tier, when a tier changes, then the new window applies from that point without retroactively re-blocking.
Implementation notes

AccessTierService currently counts distinct huId for hotels:view across all of accessLogs; the collection is time-series with a time field, so a windowed count is a filter change plus a decision about which window each tier uses.

Edge cases & risks

Rolling windows make quotas non-monotonic, which complicates any cached usage figure and every "N remaining" message. It also opens a cheap-abuse path: a trial that waits out the window gets unlimited hotels over time, so trial windows probably need a lifetime ceiling as well.

USGE-17

Attribute usage to a list or project

Proposed
Corporate travel buyerCould
As a Corporate travel buyer, I want usage tagged to the list or project it was incurred for, so that I can charge costs back to the client programme that generated them.
Acceptance criteria
  1. Given an action performed in the context of a list, when it is logged, then the list id is recorded alongside orgId and action.
  2. Given the Usage screen, when I group by list, then I see distinct hotels and actions per list with names resolved.
  3. Given an action with no list context (an ad-hoc hotel view), when it is grouped, then it appears under an explicit "unattributed" bucket.
  4. Given a list is deleted, when historical usage is shown, then the attribution survives with the list's recorded name.
  5. Given a custom project tag on a list, when usage is grouped, then the tag is available as a dimension.
Implementation notes

accessLogs.meta is a time-series metadata field that already carries orgId and action, so adding a list dimension is a schema extension there plus threading list context through export creation (which already knows its listId) and list-scoped search (hotelManager.listScopeId).

Edge cases & risks

Time-series metadata fields affect bucketing and index behaviour, so adding a high-cardinality field like list id needs care at scale. Many actions genuinely have no list context, so the unattributed bucket may dominate and make chargeback impractical.

USGE-18

Detect abnormal usage spikes

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want automated detection of unusual consumption, so that a leaked PAT or a looping integration is caught in minutes rather than on the next invoice.
Acceptance criteria
  1. Given an org's historical baseline, when hourly usage exceeds it by a configured factor, then an internal alert is raised naming the org, principal and action.
  2. Given a single PAT generating a disproportionate share of an org's usage, when the detector runs, then it is identified specifically.
  3. Given a legitimate bulk operation (a large export or a bulk subscribe), when it triggers the detector, then it can be acknowledged so the same pattern does not alert repeatedly.
  4. Given a new org with no baseline, when it starts heavy usage, then an absolute-threshold rule applies instead of a relative one.
  5. Given an alert, when it fires, then it includes a link to the org's usage view for the affected window.
Implementation notes

Reads the same time-series data as GET /stats but on call counts rather than distinct hotels; complements the rate limiter (USGE-10), which is per principal per minute and cannot see an org-level pattern spread across many tokens.

Edge cases & risks

Hotel-data usage is naturally bursty — an OTA onboarding a supply file looks exactly like an incident — so a naive detector will mostly produce false positives. Alerting on customer behaviour also needs a documented response policy or engineers will simply mute it.

USGE-19

Return rate-limit and quota headers on every response

Proposed
AI agent builderMust
As an AI agent builder, I want limit and remaining-quota headers on API responses, so that my agent can back off gracefully instead of discovering limits through 429s and 402s.
Acceptance criteria
  1. Given any authenticated API response, when it is returned, then it carries the current limit, remaining count and reset time for the applicable rate limit.
  2. Given an endpoint that consumes a product allowance, when it responds, then it also carries the remaining allowance for that product.
  3. Given a 429, when it is returned, then Retry-After is present and consistent with the headers on successful responses.
  4. Given a 402 for quota exhaustion, when it is returned, then the body distinguishes upgrade-required from top-up-required, matching metasearch_topup_required.
  5. Given headers are added, when existing clients parse responses, then nothing breaks because the headers are additive.
Implementation notes

RateLimitService already computes the values needed for Retry-After on GET /metasearch; this exposes them on every response and adds allowance headers sourced from the same entitlement checks that produce the 402s. It matters most for the MCP tools and PAT-driven agents, which have no UI to read a banner.

Edge cases & risks

Quota headers require an entitlement lookup on every request, so they must come from a cached or cheaply derived value rather than an aggregation. Publishing remaining allowance also publishes plan details to anyone holding a scoped read-only token.

USGE-20

Push usage events to a customer webhook

Proposed
DeveloperCould
As a Developer, I want usage and quota events delivered to my own endpoint, so that our internal dashboards and alerts reflect Hotel Universe consumption without polling.
Acceptance criteria
  1. Given a configured webhook, when a quota threshold is crossed or an allowance is exhausted, then a signed event is delivered.
  2. Given delivery fails, when retries are attempted, then they use exponential backoff and the failure is visible in the portal.
  3. Given repeated failures, when the retry budget is exhausted, then the endpoint is disabled and the org's admins are notified.
  4. Given a webhook secret, when an event is delivered, then it carries a signature the receiver can verify.
  5. Given no webhook is configured, when events occur, then nothing is attempted and no errors are logged.
Implementation notes

Shares the outbound delivery infrastructure proposed for subscribed-hotel change notifications (SUBS-19) — the landing page already advertises "push diffs to a webhook" while the only webhook in the platform today is inbound Stripe.

Edge cases & risks

Outbound HTTP to customer-controlled URLs introduces SSRF and secret-storage requirements the platform does not currently have. Quota events are low-volume, so the delivery system will be under-exercised and most likely to fail exactly when it matters.

USGE-21

Retain usage history beyond one year for audit

Proposed
Corporate travel buyerShould
As a Corporate travel buyer, I want usage records retained for our audit period, so that we can evidence spend and access for procurement and compliance reviews years after the fact.
Acceptance criteria
  1. Given an enterprise retention agreement, when the accessLogs TTL would delete rows, then aggregated monthly summaries are preserved beyond it.
  2. Given a request for a historical period beyond the raw window, when the report is produced, then it is served from the archive and labelled as aggregated rather than row-level.
  3. Given a standard customer, when the year passes, then the current one-year TTL behaviour is unchanged.
  4. Given a data-deletion request, when it is honoured, then archived aggregates are handled according to the stated policy and the outcome is recorded.
  5. Given the archive, when it is queried, then access is restricted to the owning org and Ubio staff, with the access itself recorded.
Implementation notes

accessLogs is a time-series collection with a hard one-year TTL; this adds a monthly rollup job writing org/action/principal aggregates to a durable collection or object storage before expiry, and points long-range GET /stats queries at the rollup.

Edge cases & risks

Aggregates cannot answer questions the raw rows could (per-hotel detail is lost), so the rollup grain must be chosen before the first year of data ages out — anything not aggregated now is gone. Longer retention also enlarges the personal-data footprint tied to principalId.

Part 9

Onboarding, growth & internal tooling

ONBD 8 built3 partial11 proposed

Onboarding, guidance, notifications & support

Hotel Universe drops a brand-new user straight into /search with an empty result table, a filter bar, an AI assistant and no data of their own. The only structured guidance that exists today is the driver.js welcome tour hosted in AppLayout.vue, the assistant's prompt cards, a set of shared empty/error/upgrade states, transient bottom-left toasts, confirm dialogs on destructive actions and a single escalation path into POST /ai/assistant/contact. This epic covers that guidance surface as built, and the durable layer it is missing: role-aware checklists, a sample list so the product is never empty, persistent notifications, lifecycle email, in-app announcements, a searchable help layer and re-engagement for trials that stall before they ever export anything.

ONBD-01

Start the welcome tour automatically on first sign-in

Built
Trial userMust
As a Trial user, I want a guided tour to start by itself the first time I land in the portal, so that I understand what the product does before I stare at an empty search screen and leave.
Acceptance criteria
  1. Given I have just completed Google sign-in for the first time, when AppLayout.vue mounts and api.getMe() returns initialSignUp: true, then the driver.js tour starts and portalTracking.track('welcome_tour_started') fires.
  2. Given getMe() returns initialSignUp: false (or the flag is absent), when the layout mounts, then no tour is started and no tracking event is sent.
  3. Given the tour starts, when the first step renders, then it is the /search welcome step with no highlighted element, using the hu-tour-welcome popover class from stylesheets/tour.css.
  4. Given getMe() fails or times out during mount, when the error is caught, then the app renders normally and the tour is silently skipped rather than blocking the shell.
  5. Given the tour is running, when I click the dimmed overlay, then nothing happens, because overlayClickBehavior: 'none' is configured.
Implementation notes

The tour lives entirely in app/layout/AppLayout.vue (not a component), configured with overlayOpacity 0.4, popoverOffset 20, stageRadius 6 and custom classes hu-tour-popover / hu-tour-welcome. The trigger is the initialSignUp flag on GET /users/me.

Edge cases & risks

initialSignUp is derived server-side, so a user who signs up, abandons before the tour completes and returns days later will still be shown the tour from step one with no memory of where they got to. Invited members joining an existing org get the same first-timer tour even though the org already has lists and subscriptions.

ONBD-02

Walk the eight tour steps across the four product surfaces

Built
Trial userShould
As a Trial user, I want the tour to physically walk me through search, lists, metasearch and direct prices, so that I see the four things I can actually do rather than reading a feature list.
Acceptance criteria
  1. Given the tour is running, when I advance through it, then it visits exactly eight steps in order: /search welcome, [data-tour-step="assistant-search-entry"], [data-tour-step="search-mode-toggle"], [data-tour-step="search-filterbar"], [data-tour-step="search-save-actions"], /lists [data-tour-step="lists-new"], /metasearch [data-tour-step="prices-filter"], and a closing /direct-prices step with no element.
  2. Given a step names a different route from the current one, when I click Next, then the router navigates first and the popover only opens once the target element resolves.
  3. Given I am on the last step, when I click the final button, then the tour destroys itself and returns me to /search.
  4. Given my org has no metasearch access, when the tour reaches the /metasearch step, then the tour must not leave me stranded on a route the requiresMetasearchAccess guard bounces to /billing.
Implementation notes

Steps and anchors are declared inline in AppLayout.vue; the anchors are data-tour-step attributes placed in HotelsSearch.vue, Lists.vue, Metasearch.vue. Router navigation goes through RouterManager, whose beforeEach guard evaluates requiresMetasearchAccess on every hop.

Edge cases & risks

Criterion 4 is the live risk: a trial org without metasearch is redirected to Billing mid-tour by guard rule 4, which breaks the tour flow and leaves the overlay over an unrelated screen. The /direct-prices closing step has no anchor, so it depends on that route rendering at all.

ONBD-03

Force the right search mode before highlighting a search step

Partial
Trial userShould
As a Trial user, I want the tour to switch the search screen into the mode a step is talking about, so that the element being highlighted is actually visible when the popover points at it.
Acceptance criteria
  1. Given a tour step declares a required mode of ai or filter, when the step activates, then the tour clicks .pr-search-mode-toggle if the current mode differs, and only then resolves the step's anchor element.
  2. Given the anchor element is not yet in the DOM, when the step activates, then the tour polls for it and gives up after 1.5 s rather than hanging.
  3. Given the poll times out, when the step is skipped, then the tour advances instead of leaving a popover pinned to nothing.
  4. Given the mode preference is persisted in localStorage hotel-universe:search-mode, when the tour finishes, then the user is left in whatever mode the last step forced — a side effect they never chose.
Implementation notes

Mode switching is done by literally querying and clicking the .pr-search-mode-toggle button rather than calling into HotelsSearch.vue state; the 1.5 s element-waiting poll is implemented in AppLayout.vue.

Edge cases & risks

Driving the UI by CSS selector click is brittle — renaming or restyling .pr-search-mode-toggle silently breaks the tour with no test coverage. Several tour anchors exist in the codebase but are unused by the eight configured steps (lists-sidebar, prices-results, assistant-empty, assistant-composer, search-ai-composer, ai-search-examples), so the markup and the tour config have already drifted apart.

ONBD-04

Record whether the tour was completed or dismissed and never repeat it

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want tour completion and dismissal recorded and the initial-tour flag cleared server-side, so that I can measure onboarding drop-off and users are not shown the tour twice.
Acceptance criteria
  1. Given the tour reaches its final step and destroys, when the destroy handler runs, then portalTracking.track('welcome_tour_completed') is fired.
  2. Given I close the tour early with the popover close control, when the destroy handler runs, then portalTracking.track('welcome_tour_dismissed') is fired instead.
  3. Given the tour is destroyed for either reason, when the handler completes, then api.dismissInitialTour() calls POST /users/me/dismiss-initial-tour so initialSignUp no longer returns true.
  4. Given the dismiss call fails (network or 5xx), when I reload the portal, then the tour starts again from step one, because the client keeps no local record of dismissal.
  5. Given events are queued by PortalTrackingManager, when the tab is closed straight after dismissal, then the pagehide flush with keepalive still delivers the event to POST /portal-events.
Implementation notes

All three tracking events (welcome_tour_started|completed|dismissed) go through PortalTrackingManager.track with a 5 s flush interval. The server-side flag reset is POST /users/me/dismiss-initial-tour, called from ApiManager.dismissInitialTour().

Edge cases & risks

Criterion 4 is a real defect path — there is no localStorage fallback, so a user with a flaky connection can be re-toured on every load. Because the flag is per-user and not per-org, switching organisation (which reloads the page) does not re-trigger it, so a user's first exposure to a second org's data has no guidance at all.

ONBD-05

Replay product tours on demand from the profile menu

Partial
Revenue managerShould
As a Revenue manager, I want to re-open the product tour whenever I want, so that I can refresh my memory on a feature I skipped past the first time without asking support.
Acceptance criteria
  1. Given I open the profile menu in AppTopbar, when the menu renders, then a "Product tours" entry is listed alongside Settings, Members, Billing, Usage, Access tokens and Logout.
  2. Given I click "Product tours", when the action fires, then the same eight-step driver.js tour starts from step one regardless of initialSignUp.
  3. Given I replay the tour and dismiss it, when the destroy handler runs, then welcome_tour_dismissed is tracked again, indistinguishable in analytics from a genuine first-run dismissal.
  4. Given my viewport is below 992px, when I click "Product tours", then nothing visible happens, because the tour is skipped on small viewports with no message.
  5. Given I want a tour of a single area (lists, metasearch, direct prices), when I open the menu, then there is no way to pick one — only the whole welcome tour is offered.
Implementation notes

The entry sits in ProfileMenu.vue, which already renders the org selector, the five settings links and the Portal: v{__PORTAL_VERSION__} / API: v{status.version} footer; it re-invokes the tour start function exported by AppLayout.vue.

Edge cases & risks

Criteria 3–5 are the named gaps: replays pollute the onboarding funnel metrics, small viewports fail silently, and there is only one tour to replay despite six unused anchors already in the markup for other areas.

ONBD-06

Offer starter prompts grouped by search and product help

Built
ProspectMust
As a Prospect evaluating the platform, I want the assistant to suggest concrete things to ask, so that I am not facing a blank chat box wondering what this thing can do.
Acceptance criteria
  1. Given I open /ai/assistant (or the inline assistant mode on /search) with no messages, when the empty screen renders, then prompt cards are shown in two groups: Search (Find a hotel, Compare prices, Build a list, Summarise reviews) and Product help (Lists and exports, Metasearch, Direct pricing, Billing).
  2. Given I click a prompt card, when the action fires, then the card's text is sent as a user message and the streaming reply begins, with ai_assistant_sent tracked.
  3. Given the conversation already has messages, when the view renders, then the prompt cards are hidden and the transcript is shown instead.
  4. Given my input exceeds the assistant's context limit, when I send, then the friendly over-limit error mapping in AiAssistantManager is shown rather than a raw API error.
  5. Given the assistant is unreachable, when I click a prompt card, then an error state is rendered in the chat and the card remains clickable for a retry.
Implementation notes

Implemented in views/AiAssistant.vue (1480 lines) backed by AiAssistantManager, whose reactive state carries messages, suggestions, typing, statusText, error and contextUsage; conversation state persists to sessionStorage hotel-universe:ai-assistant-chat (STORAGE_VERSION = 10).

Edge cases & risks

The Product help group makes the assistant a de facto documentation surface with no guarantee its answers match the current portal — there is no retrieval over the real docs at https://docs.hotel-universe.travel/guide/, so billing and tier answers can drift from what BillingPlans.vue actually shows.

ONBD-07

Confirm every action with a transient toast

Built
Customer data engineerMust
As a Customer data engineer, I want immediate feedback when an action succeeds or fails, so that I know whether my upload, export or copy actually happened.
Acceptance criteria
  1. Given any component calls the PrimeVue toast service, when the toast is added, then it renders in the global <Toast position="bottom-left" /> mounted once in AppLayout.vue.
  2. Given I copy a hotel reference id from HotelReferences.vue, a license number from HotelCompliance.vue, or a PAT secret from AccessTokens.vue, when the copy succeeds, then a confirmation toast is shown.
  3. Given a bulk direct-pricing enable partially fails, when the response returns, then a partial-warning toast names how many hotels succeeded rather than claiming full success.
  4. Given a list upload fails with an HTML error body, when the toast is composed, then the server HTML is sanitised before display.
  5. Given a toast is the only record of an event, when it auto-dismisses, then the information is gone permanently — there is no history to consult.
Implementation notes

Toast usage spans list save/append/delete, export started/processing, copy-to-clipboard, member invite/remove, invitation cancel, direct-pricing enable/disable/schedule, rich-content subscribe, metasearch and price-insights errors, upload success/failure, createVirtualSelectionSaveToast in utils/selection.ts, and org-switch storage failure. The single shared copy constant is app/copy/toastTexts.tsRICH_CONTENT_SUBSCRIBE_MESSAGE.

Edge cases & risks

Criterion 5 is the structural problem: long-running work (list processing, export generation, direct-price refresh batches) reports through toasts that vanish, so a user who switches tabs mid-job has no way to learn the outcome except by re-opening the screen. Bottom-left placement also collides with the collapsible mobile selection rail on /search.

ONBD-08

Require explicit confirmation before anything destructive or billable

Built
Organisation adminMust
As an Organisation admin, I want a confirmation dialog before deletions and billing-affecting actions, so that one mis-click cannot destroy a list or change what we are charged.
Acceptance criteria
  1. Given I trigger delete list, delete list entry, delete token, remove member, cancel invitation, make list static, request/enable/re-enable/disable direct pricing, cancel a scheduled disable, a bulk enable/disable, or cancel a subscription, when the action fires, then $confirm.require opens the shared <ConfirmDialog /> from AppLayout.vue.
  2. Given the dialog is open, when I choose the reject action, then no API call is made and the underlying state is untouched.
  3. Given I accept a direct-pricing disable, when the confirm resolves, then the copy makes the billing-cycle-end semantics explicit ("Disables on <date>") rather than implying immediate removal.
  4. Given the confirmed API call then fails, when the error returns, then an error toast is raised and the UI state is rolled back to pre-action.
  5. Given I delete a list containing subscribed hotels, when the dialog renders, then it does not currently tell me what I am giving up in subscription terms.
Implementation notes

One <ConfirmDialog /> instance in app/layout/AppLayout.vue serves the whole app; call sites are spread across Lists.vue, ListEntries.vue, ListHeader.vue, AccessTokens.vue, Members.vue, HotelDetailRail.vue, DirectPrices.vue and BillingCurrentPlan.vue.

Edge cases & risks

Confirmation copy is written per call site with no shared constants beyond toastTexts.ts, so the wording and the severity styling vary; the README's own guideline ("dangerous actions below content in red + confirmation") is applied inconsistently. Criterion 5 means a destructive action with real money attached is confirmed with generic copy.

ONBD-09

Reuse one set of empty, error, loading and upgrade states

Built
ProspectShould
As a Prospect, I want every screen with nothing to show to explain why and what to do next, so that an empty product feels like a starting point rather than a broken page.
Acceptance criteria
  1. Given a screen has no data, when it renders, then it uses components/EmptyState.vue with an icon, title, text and optional slot, in either the glass or plain variant.
  2. Given a fetch fails, when the screen renders, then components/ErrorState.vue is shown with a retry affordance where the manager supports reload.
  3. Given a screen is gated by tier, when access is denied, then components/UpgradeRequired.vue renders the lock legend, main message, value proposition and either the "View Billing" / "Contact Sales to Upgrade" buttons or the showButtons: false variant with "Contact sales at hello@ubio.ai".
  4. Given data is in flight, when the screen renders, then components/Loading.vue or a skeleton is shown and no stale content is displayed, per the README rule "no stale content, show skeleton".
  5. Given a new empty state is added, when it is written, then its copy string is inlined in the component — there is no central copy file for empty states.
Implementation notes

The four shared components are EmptyState.vue, ErrorState.vue, UpgradeRequired.vue and Loading.vue. Representative copy includes "Add filters to start your search", "There aren't any lists.", "No items to review.", "No rejected or unmatched rows.", "No active direct pricing hotels yet.", "All hotels matched", "No direct website data scraped yet", "There isn't enough calendar pricing data for this property yet.", "No comparable competitors with pricing data were found nearby.", "No conversations yet." and "Choose a list above to analyze its market pricing."

Edge cases & risks

Most empty states describe the absence but do not offer the next action — "There aren't any lists." has a CTA, "No usage data found" does not. Because copy is inlined per component, tone and capitalisation drift from the README's "Upload list" not "Upload List" rule, and nothing is translatable (there is no i18n layer at all).

ONBD-10

Escalate from the AI assistant to a human with the transcript attached

Built
Trial userMust
As a Trial user, I want to hand my assistant conversation to a human when the assistant cannot help, so that I do not have to re-explain the problem from scratch in an email.
Acceptance criteria
  1. Given I am in a conversation, when I choose to contact support, then AiAssistantManager.state.supportView moves from chat to escalate and a message form is shown, with ai_assistant_contact_started tracked.
  2. Given I submit the form, when the request is sent, then api.supportContact(message, transcript, pageUrl) posts to POST /ai/assistant/contact and ai_assistant_contact_submitted is tracked.
  3. Given the transcript exceeds 20,000 characters, when it is assembled, then it is truncated with a truncation prefix so the request still succeeds.
  4. Given the post succeeds, when the response returns, then supportView moves to done and a confirmation is shown.
  5. Given the post fails, when the error returns, then I stay on the escalate view with my typed message intact and an error message, rather than losing what I wrote.
Implementation notes

AiAssistantManager owns the supportView: chat | escalate | done state machine; the payload carries the message, the truncated transcript and the current page URL so support can see which screen the user was on.

Edge cases & risks

Transcripts can contain a customer's hotel ids, list names and pricing questions — commercially sensitive material leaving the product with no stated retention policy. There is no ticket id returned to the user and no in-app thread, so the escalation is fire-and-forget from the customer's point of view.

ONBD-11

Reach a human through the published contact addresses

Partial
ProspectShould
As a Prospect, I want an obvious way to email Ubio when I am blocked, so that I can get a commercial or technical answer without an account.
Acceptance criteria
  1. Given I hit the trial hotel-view limit, when GET /hotels/{huId} returns 402, then the message reads "Upgrade to view. Contact sales at hello@ubio.ai".
  2. Given I open a sales-only plan tier in BillingPlans.vue, when I click "Contact Us", then a mailto:hello@ubio.ai?subject=<Product> Plan is opened with the product pre-filled.
  3. Given I am on the landing page, when I click "Talk to sales" in LandingNav, then the same SALES_MAILTO (hello@ubio.ai) opens.
  4. Given I receive an organisation invitation email, when I read the footer, then the support address shown is support@ubio.ai, a different address from every in-product contact point.
  5. Given I email either address, when I do so, then no ticket is created in the product and nothing about the exchange is visible in the portal.
Implementation notes

hello@ubio.ai appears in the API's 402 upgrade message, in UpgradeRequired.vue's showButtons: false variant, in BillingPlans.vue sales CTAs and in components/landing/links.ts as SALES_MAILTO. support@ubio.ai appears only in the SendGrid sendInvitationEmail footer — the single email template the platform sends.

Edge cases & risks

Two addresses with no documented split means sales gets support requests and vice versa. Because mailto: is the whole mechanism, there is no qualification, no routing, no SLA and no way to correlate an inbound email with the org, tier or error the user hit — the exact context POST /ai/assistant/contact already collects for the assistant path.

ONBD-12

Onboard users on small viewports instead of skipping them

Proposed
Hotel ownerShould
As a Hotel owner checking rates from a phone or a small laptop, I want onboarding guidance sized for my screen, so that my first session is not silently guidance-free just because my window is narrow.
Acceptance criteria
  1. Given window.innerWidth < 992, when AppLayout.vue mounts with initialSignUp: true, then a compact bottom-sheet onboarding flow runs instead of the driver.js spotlight tour being skipped entirely.
  2. Given the compact flow runs, when it completes or is dismissed, then the same welcome_tour_completed / welcome_tour_dismissed events fire with a variant: 'compact' metadata field and POST /users/me/dismiss-initial-tour is called.
  3. Given I resize from below 992px to above during a session, when the resize settles, then the tour is not restarted mid-session.
  4. Given the compact flow references a route the mobile Drawer menu hides, when the step activates, then it opens the drawer rather than pointing at an element that is not rendered.
  5. Given the compact flow cannot resolve a step, when it times out, then it degrades to a static two-card summary rather than showing nothing.
Implementation notes

Builds on the existing tour scaffolding in AppLayout.vue and the mobile layout state in app/layout/composables/layout.ts (staticMenuMobileActive, toggleMenu); reuses the same tracking events through PortalTrackingManager.

Edge cases & risks

Much of the portal is genuinely desktop-shaped — CompetitorPriceMatrix.vue's date×hotel grid and HotelPriceInsights.vue's chart.js canvases do not fit a phone — so a mobile tour risks advertising screens that are unusable, which is worse than no tour. The 992px threshold is currently the only mobile/desktop signal in the app.

ONBD-13

Give each role its own onboarding checklist

Proposed
OTA product managerShould
As an OTA product manager, I want an onboarding checklist tailored to what my kind of customer actually does, so that I am guided towards supply-file matching rather than a generic feature tour aimed at hotel owners.
Acceptance criteria
  1. Given I complete first sign-in, when the portal loads, then I am asked once which of the landing page's four segments I belong to — OTAs / aggregators, hotel owners & revenue managers, travel agencies & corporate buyers, or developers & AI agents — and the answer is stored on my user record.
  2. Given I picked "OTAs / aggregators", when the checklist renders, then its items are upload a CSV to /lists/new, resolve the review queue in ListView, subscribe the confirmed hotels, and run an export.
  3. Given I picked "hotel owners & revenue managers", when the checklist renders, then its items are find my property in /search, pick a comp set via CompetitorCriteria, open /hotels/:id/competitor-prices, and enable direct pricing.
  4. Given I picked "developers & AI agents", when the checklist renders, then its items are create a PAT in /tokens, call GET /search, and install the MCP server per mcp-setup.html.
  5. Given I complete a checklist item through normal product use, when the corresponding telemetry event arrives (list_csv_upload:id|details, metasearch_executed, access_token_created, …), then the item ticks itself without me marking it manually.
  6. Given I dismiss the checklist, when I reload, then it stays dismissed and is reachable again from the profile menu next to "Product tours".
Implementation notes

Completion detection can be driven entirely off the existing portalTracking event vocabulary posted to POST /portal-events, so no new instrumentation is needed; the role would live alongside initialSignUp on GET /users/me and the checklist would render in the /search right rail where the "Assistant suggests" tip block already sits.

Edge cases & risks

Roles are self-declared and often wrong; an OTA analyst may pick "developer" and be steered to PATs when they needed the review queue, so the checklist must stay switchable. Several role items are tier-gated (metasearch, direct pricing, subscribe), so a trial user's checklist would otherwise be full of items they cannot complete.

ONBD-14

Preload a demo list so the product is never empty

Proposed
Trial userMust
As a Trial user, I want a small sample list already in my account, so that I can see lists, review queues, exports and price comparison working on real data before I own any data myself.
Acceptance criteria
  1. Given a new organisation is auto-created at tier TRIAL on first Google login, when the org is provisioned, then a read-only demo list named "Sample: Zagreb comp set" is created with a handful of resolved huIds.
  2. Given I open /lists, when the demo list renders, then it carries a distinguishing tag alongside the existing Dynamic tag so it is never confused with my own data.
  3. Given I open the demo list, when ListView.vue renders, then the Confirmed Hotels tab is populated and the export drawer works within the trial preview export allowance.
  4. Given I try to delete or rename the demo list, when the action fires, then it is permitted — the sample must not become undeletable clutter.
  5. Given the demo hotels are viewed, when access is logged, then those hotels:view rows are excluded from ACCESS_TIER_TRIAL_LIMIT (default 100 distinct hotels) so the sample does not eat the trial quota.
  6. Given demo huIds are later merged or removed by the ingest pipeline, when the list is opened, then missing entries render as "Hotel not found" rather than erroring the page.
Implementation notes

Hooks into the same first-login path that already creates "<name>'s Organization" at tier TRIAL and processes pending invitations; the list itself is an ordinary lists document with listEntries rows, so ListStatsManager, ListExportsManager and the price matrix all work unchanged. The landing page's own LiveChat mock already uses a Zagreb comp set, so the sample matches the marketing narrative.

Edge cases & risks

Criterion 5 matters commercially — without the exclusion, a sample list would consume a meaningful slice of the 100-hotel trial allowance that is counted all-time from accessLogs. A hard-coded sample also needs a maintenance owner, or it will slowly fill with stale properties.

ONBD-15

Open contextual help in a drawer backed by a searchable knowledge base

Proposed
Travel agency consultantShould
As a Travel agency consultant, I want help I can search from inside the screen I am stuck on, so that I do not lose my filters by navigating away to a docs site in another tab.
Acceptance criteria
  1. Given I am on any authenticated route, when I press the help affordance in AppTopbar, then a right-hand Drawer opens over the current screen without unmounting it or clearing filter state.
  2. Given the drawer opens on /search, when it renders, then it defaults to articles relevant to that route (filters, saved searches, selection limits) rather than a generic index.
  3. Given I type in the drawer's search box, when results return, then articles are ranked over the same content published at https://docs.hotel-universe.travel/guide/, including the MCP setup and pricing-API pages.
  4. Given an article has a canonical docs URL, when I open it, then a "Read on docs" external link is offered so deep content is not duplicated in the portal.
  5. Given the knowledge-base backend is unreachable, when the drawer opens, then it degrades to the existing static DOCS_URL links rather than showing a broken panel.
  6. Given no article matches my query, when results are empty, then the drawer offers "Ask the assistant" and the support escalation path instead of a dead end.
Implementation notes

Reuses the PrimeVue Drawer pattern already used for the exports drawer in ListView.vue, and the docs constants in components/landing/links.ts (DOCS_URL, DOCS_METASEARCH_URL, DOCS_DIRECT_ARI_URL). Route-to-topic mapping can key off route.name, the same key portalTracking already sends with page_view.

Edge cases & risks

Two sources of truth (assistant answers and knowledge-base articles) can disagree; the drawer should share the assistant's retrieval corpus rather than maintaining a second one. Indexing docs into a portal-side search adds an ops surface that currently does not exist.

ONBD-16

Publish an in-app changelog and what's-new prompts

Proposed
Customer data engineerCould
As a Customer data engineer, I want to see what changed in the portal and the API, so that I find out about a new export type or a new filter without discovering it by accident.
Acceptance criteria
  1. Given a release is published, when I next load the portal, then an unobtrusive "what's new" indicator appears next to the version footer in ProfileMenu.vue.
  2. Given I open the changelog, when it renders, then entries are grouped by date and tagged Portal or API, and the current __PORTAL_VERSION__ and status.version from GET /status are highlighted.
  3. Given I have read the latest entry, when I reload, then the indicator clears and does not return until the next release.
  4. Given a change affects a tier or quota, when the entry renders, then it links directly to /billing so the commercial impact is one click away.
  5. Given the changelog feed fails to load, when the menu renders, then the version footer renders normally with no error surfaced to the user.
Implementation notes

The profile menu already fetches and shows Portal: v{__PORTAL_VERSION__} and API: v{status.version}, so the version pair needed to decide "is there something new" is already in the client; entries would be a static feed rather than a new collection.

Edge cases & risks

The portal repo README already declares itself deprecated in favour of packages/portal in the API monorepo, so a changelog must be sourced from whichever repo actually ships, or it will silently stop updating. Announcing API changes in a portal-only surface misses PAT and MCP consumers entirely.

ONBD-17

Replace transient toasts with a notification centre

Proposed
Organisation adminMust
As an Organisation admin, I want a persistent list of notifications with read and unread state, so that I can find out that last night's export finished or a payment failed without having been watching the screen.
Acceptance criteria
  1. Given a notable event occurs for my organisation, when it is recorded, then it appears in a notification centre reachable from AppTopbar with an unread count badge.
  2. Given an export moves from processing to ready, when the status changes, then a notification is created with a direct download action, in addition to the existing toast raised by ListExportsManager.
  3. Given a list finishes processing or fails, when ListPollingManager observes the terminal status, then a notification records it, including the list error text for status: error.
  4. Given invoice.payment_failed is received by the Stripe webhook, when it is handled, then a notification is created for org admins rather than the event being swallowed as it is today.
  5. Given I read a notification, when I mark it read, then the unread count decrements for me only — read state is per user, not per org.
  6. Given more than 30 days of notifications exist, when the centre loads, then older entries are paged rather than loaded in one response.
Implementation notes

Events already detectable client-side come from ListPollingManager (2.5 s loop), ListExportsManager polling, and DirectPricingManager batch polling (POLL_INTERVAL_MS = 3000); server-side sources are the existing Stripe webhook handlers (checkout.session.completed, customer.subscription.updated|deleted, invoice.payment_failed) and export completion in ExportService.

Edge cases & risks

The webhook handler currently swallows errors and always returns 200, so payment-failure notifications would silently not be created unless that path is hardened first. Export download URLs are signed for only 15 minutes and objects expire after 30 days, so a notification's download action must re-sign rather than store a URL.

ONBD-18

Send lifecycle and digest emails beyond the invitation

Proposed
Revenue managerShould
As a Revenue manager, I want email about the things I care about — my export is ready, my trial is nearly used up, my comp set moved — so that I do not have to keep the portal open to stay informed.
Acceptance criteria
  1. Given my export completes, when ExportService finishes writing to GCS, then an "export ready" email is sent with a link to ListView's exports drawer, not a raw signed URL.
  2. Given my org crosses 80% of ACCESS_TIER_TRIAL_LIMIT distinct hotels viewed, when the threshold is crossed, then a quota-warning email is sent once, with a link to /billing.
  3. Given I have a metasearch or direct-pricing subscription, when a weekly digest runs, then I receive a summary of the largest rate gaps in my comp set, sourced from the same data as HotelPriceInsights.vue.
  4. Given I opt out of a category, when the preference is saved, then no further emails of that category are sent to me, and transactional emails (invitation, payment failure) remain unaffected.
  5. Given SendGrid rejects a send, when the failure returns, then the failure is logged and retried rather than deleting any underlying record.
  6. Given a digest has no material change to report, when it is due, then it is suppressed rather than sent empty.
Implementation notes

There is exactly one email today — sendInvitationEmail over SendGrid (SENDGRID_API_KEY, SENDER_EMAIL), subject "You've been invited to join ${organizationName} on Hotel Universe", CTA ${FRONTEND_URL}/invite/accept?token=…, footer support@ubio.ai. Quota data already exists in the accessLogs time-series aggregated by GET /stats/.

Edge cases & risks

Criterion 5 calls out a live pattern to avoid: today, if sendInvitationEmail throws, the invitation record is deleted — coupling delivery to data integrity. Digest emails touching competitor pricing need care, since a rate-gap summary in an inbox is more sensitive than the same numbers behind auth.

ONBD-19

Announce incidents and maintenance inside the product

Proposed
DeveloperShould
As a Developer whose jobs call the API on a schedule, I want to see in the portal when scraping, metasearch or exports are degraded, so that I can tell the difference between my bug and your outage.
Acceptance criteria
  1. Given an incident is declared, when I load any authenticated route, then a dismissible banner is shown above the content in AppLayout.vue naming the affected component (search, metasearch, direct pricing, exports, ingest).
  2. Given planned maintenance is scheduled, when it is within 24 hours, then an informational banner shows the window in my locale and the expected impact.
  3. Given an incident affects only metasearch, when I am on a route without metasearch access, then the banner is not shown to me.
  4. Given I dismiss a banner, when the incident's severity later escalates, then the banner reappears once.
  5. Given no incident is active, when the app loads, then no additional request blocks rendering — the announcement fetch is non-blocking and failure is silent.
Implementation notes

Would replace the hard-coded "Status: all systems green" string in LandingFooter with a real signal, and could be surfaced from the same feed. Access-scoping in criterion 3 can reuse AccessManager.hasMetasearchAccess() and hasDirectPricingRefreshAccess().

Edge cases & risks

Degradation in this platform is often partial and silent — a scraper task failing on TripAdvisor leaves stale data with no error surface — so incident detection needs real signals from the scraping service, not just manual declarations. A banner that is wrong is worse than none, because it trains users to ignore it.

ONBD-20

Hand a stuck conversation to live chat with full context

Proposed
ProspectCould
As a Prospect close to buying, I want to reach a human in real time from the assistant, so that a pricing or coverage question does not cost me a day of email round-trips.
Acceptance criteria
  1. Given I am in the escalate support view, when live chat is available during staffed hours, then I am offered "Chat now" alongside the existing message form.
  2. Given I start a live chat, when the session opens, then the agent sees my org, tier, current route, the truncated transcript and my recent errors without me repeating them.
  3. Given live chat is outside staffed hours, when I open the escalate view, then only the existing POST /ai/assistant/contact path is offered, with an expected response time.
  4. Given the chat ends, when the session closes, then a transcript is attached to the same support record as the original escalation so there is one thread, not two.
  5. Given I decline to share the transcript, when I start the chat, then the session opens with org and tier only and the transcript is withheld.
Implementation notes

Extends the supportView: chat | escalate | done state machine in AiAssistantManager and the payload already assembled for POST /ai/assistant/contact (message, transcript capped at 20,000 chars, page URL); org and tier are available from AccessManager.currentAccessInfo.

Edge cases & risks

Criterion 5 is a genuine privacy requirement, not a nicety — transcripts routinely contain a customer's comp set and supply file details. Staffing a live channel for a self-serve product with four tiers per product is an operational commitment that should be gated to higher tiers or to trials in their conversion window.

ONBD-21

Drive every new org to a first successful export

Proposed
OTA supply managerMust
As an OTA supply manager, I want the product to walk me to my first completed export, so that I reach the moment where the platform has obviously delivered value instead of stalling halfway through a CSV upload.
Acceptance criteria
  1. Given my org has never created an export, when I sign in, then a persistent milestone card shows the remaining steps: upload or build a list, resolve the review queue, create an export, download it.
  2. Given my list has entries in needs_review, when the milestone card renders, then it deep-links to ListView with ?tab=review and shows the outstanding count from ListStatsManager.needsReview.
  3. Given I create an export, when it reaches ready and I download it, then the milestone completes, export:download:{type} is observed, and the card is replaced by a next-step suggestion (subscribe, compare prices, or issue a PAT).
  4. Given I am on the trial tier, when the milestone card renders, then it states the 100-hotel export cap up front rather than letting me discover it at download time.
  5. Given my list ends up with zero confirmed hotels, when the milestone stalls, then the card offers the unmatched-rows view and the support path instead of repeating "create an export".
  6. Given an export fails, when the status turns error, then the milestone shows the generic failure message and a retry rather than appearing complete.
Implementation notes

Every signal needed already exists: ListStatsManager (total, hotelsTotal, needsReview, unmatched), ListExportsManager statuses, and the accessLogs actions lists:create, export:create:{full,preview,reviews}, export:download:{full,preview,reviews} aggregated by GET /stats/.

Edge cases & risks

The trial export cap (100 for full/reviews, unlimited for preview) means the "successful export" a trial reaches may be a preview export that under-sells the product; the milestone should prefer preview to guarantee completion but say clearly what a full export adds. Fixed generic error copy ("Export failed. Please try again later…") gives the card nothing actionable to show on failure.

ONBD-22

Re-engage trials that stall before they see value

Proposed
Ubio sales repShould
As a Ubio sales rep, I want stalled trials nudged automatically and flagged to me, so that I spend my time on accounts that are actually stuck rather than on ones that are progressing fine.
Acceptance criteria
  1. Given a trial org has signed up but logged no lists:create within 3 days, when the re-engagement job runs, then an in-app prompt and an email offer the CSV upload flow and the demo list.
  2. Given a trial org has created a list but never an export within 7 days, when the job runs, then the nudge targets the review queue or the export drawer depending on whether needsReview is non-zero.
  3. Given a trial org has consumed more than 80% of ACCESS_TIER_TRIAL_LIMIT, when the job runs, then the account is flagged to sales as expansion-ready rather than stalled.
  4. Given a user dismisses two nudges in a row, when the job next runs, then no further in-app nudges are shown to that user for 30 days.
  5. Given an org has already converted to a paid richContent, metasearch or smartFeed subscription, when the job runs, then it is excluded from trial re-engagement entirely.
  6. Given telemetry is missing for an org (no portal-events rows), when the job runs, then the org is treated as unknown rather than stalled, so API-only customers are not spammed.
Implementation notes

Segmentation can be computed from the accessLogs time-series (lists:create, hotels:view, export:create:*) and POST /portal-events telemetry, joined to organizations.tier and billing.stripeStatus; delivery reuses the SendGrid sender and the proposed notification centre.

Edge cases & risks

Criterion 6 protects the platform's most valuable segment — developers and AI agents using PATs and MCP never generate portal telemetry, so naive activity scoring would classify the best-fit users as dead. Nudging a shared org address annoys everyone in memberIds, so nudges should target the user who signed up, not the whole org.

MKTG 7 built3 partial12 proposed

Landing site, self-serve conversion & trust

/landing is the platform's entire public face: a single Vue route with requiresAuth: false that every unauthenticated visitor is redirected to, composed of nine landing components ending in a footer that claims "Status: all systems green". It sells three products, six MCP tools and four use-case segments, then funnels every visitor through exactly one door — Google OAuth. This epic covers the marketing surface as built (hero, products, MCP showcase, flow, use cases, footer, the live /pricing page) and the conversion and trust machinery it lacks: non-Google signup, real public prices, a sandbox, an indexable content surface, proof, a status page and a compliance trust centre.

MKTG-01

Make the landing page the unauthenticated entry point

Built
ProspectMust
As a Prospect who followed a deep link, I want to land on a page that explains the product and can sign me in, so that I am not dumped on a bare login form with no context.
Acceptance criteria
  1. Given I request any route whose meta.requiresAuth !== false while unauthenticated, when the router.beforeEach guard runs, then my intended URL is stored in sessionStorage as redirectUrl and I am sent to next({ name: 'Landing' }).
  2. Given I then sign in from the landing page, when the OAuth callback completes, then goToRedirectUrl() returns me to the originally requested route after a same-origin check.
  3. Given I am already authenticated, when I navigate to /landing or /login, then the guard sends me to next({ name: 'Home' }), which redirects to HotelsSearch.
  4. Given I hit an unknown path, when the catch-all /:path(.) route matches, then I am redirected to Home and, if unauthenticated, on to Landing.
  5. Given the OAuth provider returns hash fragments (state=, session_state=, code=), when router.beforeResolve runs, then they are stripped and the route is replaced so the address bar stays clean.
  6. Given sessionStorage is unavailable, when the redirect is stored, then navigation still proceeds and I simply land on Home after sign-in.
Implementation notes

All of this is in app/managers/RouterManager.ts: the Landing route (views/Landing.vue, requiresAuth: false), the guard rules, and the helpers goHome(), goToLogin(), setRedirectUrl(url), goToRedirectUrl(clear = true). /login (layout/AppLogin.vue) survives only as a fallback.

Edge cases & risks

Making marketing the 401 destination means every expired-session user is shown the sales pitch rather than a "your session expired" message, which reads as a bug to paying customers. The redirectUrl is stored per tab, so a session that expires in one tab and is renewed in another loses the deep link.

MKTG-02

Lead with the positioning statement, the coverage stat and two CTAs

Built
ProspectMust
As a Prospect, I want the first screen to tell me what this is and what to do next, so that I can decide in ten seconds whether it is relevant to me.
Acceptance criteria
  1. Given I load /landing, when LandingHero renders, then the eyebrow reads "HOTEL DATA & RATE INTELLIGENCE", the headline reads "Bring your list. See every rate." and the sub-line reads "Hotel content, OTA price comparison and direct rates — one API, priced per product."
  2. Given the hero renders, when the lede is shown, then it states "Start with your hotels. Built for OTAs and hotel owners…" and cites HOTELS_INDEXED.label = "1M+" hotels indexed.
  3. Given the hero CTAs render, when I click "Sign in to portal", then the shared components/landing/useSignIn.ts OAuth flow starts; when I click "Install MCP server", then I am taken to the MCP setup docs.
  4. Given I am already authenticated, when the nav renders, then the primary CTA label switches to "Open portal" instead of "Sign in to portal".
  5. Given the audience strip renders, when I read it, then it names "OTAs · Aggregators · Hotel owners · Revenue managers".
  6. Given the sign-in call throws, when the error is caught, then it is logged and the page stays usable rather than leaving a dead button state.
Implementation notes

Copy and constants live in components/landing/links.ts (HOTELS_INDEXED, METASEARCH, BRANDS_TRACKED, DOCS_URL, SALES_MAILTO, UBIO_ABOUT_URL); LandingNav carries "Talk to sales" (mailto:hello@ubio.ai), "Read the docs", "How it works" and the portal CTA.

Edge cases & risks

"1M+ hotels indexed" is a hard-coded marketing constant with no link to the actual canonicalData6 count, so it can drift from reality in either direction. The two hero CTAs both assume the visitor already wants an account — there is no "see it first" path.

MKTG-03

Show the assistant working in an animated chat mock

Built
AI agent builderShould
As an AI agent builder, I want to watch a realistic prompt-to-list interaction on the landing page, so that I understand the tool-chaining claim without installing anything.
Acceptance criteria
  1. Given the hero renders, when the LiveChat mock animates, then a user prompt is typed out: "Find 4★+ hotels within 1 km of the centre of Zagreb and add them to my Zagreb comp set".
  2. Given the prompt completes, when the assistant replies, then it shows the status line "On it — searching 4★+ properties within 1 km of the main square", the machine-readable filter line city:Zagreb stars:>=4 radius:1km near:center, and "→ 14 matches · deduped to canonical records".
  3. Given the result cards render, when they animate in, then a sample property ("Esplanade Zagreb Hotel", "★★★★★ · 0.4 km to center · 9.2/10") is shown with "→ added · subscribed for 365 days of updates".
  4. Given the sequence finishes, when the closing line renders, then it reads "Added all 14 to Zagreb comp set. Want me to pull OTA prices for them next?".
  5. Given the component unmounts mid-animation, when cleanup runs, then all timers created by useTyper.ts are cleared so no work continues in the background.
  6. Given the visitor prefers reduced motion, when the mock renders, then it should present the final state rather than animating — a gap in the current implementation.
Implementation notes

Driven by components/landing/useTyper.ts (character-by-character typing with setTimeout + setInterval and an unmount teardown) inside the LandingHero/LiveChat mock; the header reads "Hotel Universe AI Assistant · Search · find hotels · add to a list".

Edge cases & risks

The mock advertises behaviour the real assistant must match — "deduped to canonical records", automatic subscription for 365 days — and a mismatch between mock and product is a credibility problem in the first 30 seconds. Criterion 6 is an accessibility gap, and continuously animating hero content is also a measurable battery and CPU cost on mobile.

MKTG-04

Present the three products with their own visuals and docs links

Built
OTA product managerMust
As an OTA product manager, I want the three products explained separately with links to their docs, so that I can work out which one I need to buy and how it is priced.
Acceptance criteria
  1. Given LandingProducts renders, when the section loads, then three cards are shown — Hotel details, Metasearch prices (Metasearch across OTAs) and Direct pricing / Direct ARI — under the framing "Three composable products built on a single canonical hotel graph."
  2. Given the Hotel details card renders, when I read it, then it promises "deduped records … each one canonical, so your catalogue stops disagreeing with itself. Subscribe and get 365 days of automatic updates" with the CTA "Browse the schema".
  3. Given the Metasearch card renders, when I read it, then it states "who is undercutting you, on which dates, and by how much, against the competitors you pick" with the CTA "View pricing API" pointing at DOCS_METASEARCH_URL.
  4. Given the Direct pricing card renders, when I read it, then it describes "per room, with full rate plans, LOS rules and cancellation policies. Up to 365-day lookahead", shows the "Refresh cadence" and "Global coverage" attributes, and carries the disclaimer "These are the hotel's own room rates — not what Hotel Universe charges" with the CTA "Direct pricing guide" (DOCS_DIRECT_ARI_URL).
  5. Given each card has a mini visual, when it renders, then MiniDetail / MiniMeta / MiniAri show sample content (property type, star rating; OTA rows versus Direct rate; "Direct rates · 7 days", "Deluxe Queen", "Sold out").
  6. Given a docs link target is unreachable, when I click it, then I leave the site to a 404 on the docs host — the landing page does not verify targets.
Implementation notes

LandingProducts plus the mini visual components in components/landing/; docs URLs are the constants DOCS_URL, DOCS_METASEARCH_URL, DOCS_DIRECT_ARI_URL.

Edge cases & risks

The disclaimer on Direct ARI is legally important and easy to lose in a redesign — it is the only place the page distinguishes hotel rates from Ubio's own charges. The mini visuals use invented sample hotels; if they ever look like live data, they become a claim about coverage.

MKTG-05

Sell the MCP server as a first-class install path

Built
AI agent builderMust
As an AI agent builder, I want the exact MCP config and tool list on the marketing page, so that I can judge fit and copy the snippet without signing up first.
Acceptance criteria
  1. Given McpShowcase renders, when the section loads, then it is framed "AI-first, by design. Describe the hotels you need. Get a list back." and explains "The assistant chains tools instead of just answering questions: search → match against canonical records → add to a list → pull OTA and direct prices → export. All from one prompt."
  2. Given the config panel renders, when I read it, then it shows a claude_desktop_config.json sample with "command": "npx", "args": ["@hoteluniverse/mcp"] and "env": { "HU_API_KEY": "hu_•••••••" }.
  3. Given the tool list renders, when I read it, then six tools are named with descriptions: hotels.search (find by query, geo, amenity), hotels.get (full record + photos), reviews.summarise (themed sentiment), prices.metasearch (compare OTAs vs direct), prices.ari (direct booking-engine rates), lists.create / .export.
  4. Given the bullets render, when I read them, then they claim "Scoped API keys — issue read-only tokens per agent or per workspace", "Streaming results — long-running searches surface partial state instantly" and "Workspace memory — the assistant remembers your active lists and price queries".
  5. Given the client pills render, when I read them, then Claude, ChatGPT and Cursor are shown.
  6. Given I click "Install MCP server", when I follow the link, then I reach https://docs.hotel-universe.travel/guide/mcp-setup.html, the same target the portal's assistant MCP card uses.
Implementation notes

McpShowcase + McpPanel in components/landing/; the same docs target is linked from the AI assistant's left-rail MCP connection card in views/AiAssistant.vue.

Edge cases & risks

The "scoped API keys — read-only per agent" claim is ahead of the platform: PATs issued by POST /tokens carry a fixed scope set (exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view) with no per-token scope selection and no expiry, so a security-conscious evaluator will find the claim unsupported. The env var name shown (HU_API_KEY) must match the published package or the copied snippet fails on first run.

MKTG-06

Explain the four-step flow from CSV to fresh data

Built
OTA supply managerMust
As an OTA supply manager, I want the ingest-to-freshness path spelled out, so that I can see how my existing supply file becomes usable data.
Acceptance criteria
  1. Given LandingFlow renders, when the section loads, then it is framed "Everything depends on the starting point" and closes on the tagline "Find. Subscribe. Stay fresh for 365 days."
  2. Given step 1 renders, when I read it, then it says "Upload a CSV of the hotels you care about. No need to reshape it first — extra columns are ignored", matching the API's CSV parser which keeps a row only if it carries at least one recognised id column.
  3. Given step 2 renders, when I read it, then it lists the id types "Booking, Expedia, Google Hotels, TripAdvisor, Trivago, Agoda or your own Hotel Universe IDs" and promises "Pick the type and every row resolves exactly".
  4. Given step 3 renders, when I read it, then it describes the AI matching path — "confident rows land straight in the list, uncertain ones go to a review queue for a human call, and unmatched rows stay visible for follow-up" — matching ListReviewQueue.vue's confirmed | needs_review | unmatched states.
  5. Given step 4 renders, when I read it, then it says "Subscribe the list for 365 days of automatic updates, then layer on metasearch prices or direct rates. Export to CSV / JSON or push diffs to a webhook."
  6. Given a claim in the flow has no shipped implementation, when a prospect asks about it, then sales has no in-product answer — see risks.
Implementation notes

LandingFlow in components/landing/; the review-queue narrative maps directly onto ListReviewQueue.vue's reason codes missing_matching_data, id_lookup_not_found, no_candidates_above_threshold, all_candidates_rejected.

Edge cases & risks

"Push diffs to a webhook" is the standout gap — there is no outbound webhook feature anywhere in the API clone, only inbound Stripe. Step 2's promise that "every row resolves exactly" also overstates the ladder in SourceHotelDataRepo.findHuId, where only GoogleHotels ids are deterministic and Trivago/Agoda ids exist as REF_EXTRA_OTA_NAMES in the portal but are not part of the documented hotelIdType enum on GET /search.

MKTG-07

Address four buyer segments with their own typical ask

Built
Corporate travel buyerShould
As a Corporate travel buyer, I want to see my own situation described in my own words, so that I can tell whether this product is for people like me.
Acceptance criteria
  1. Given LandingUseCases renders, when the section loads, then four segments are shown under "Who's using Hotel Universe": OTAs / aggregators, Hotel owners & revenue managers, Travel agencies & corporate buyers, Developers & AI agents.
  2. Given each segment renders, when I read it, then it carries a headline and a "Typical ask" quote — for example "Ship a hotel surface in days, not quarters." with "Here is our supply file — which of these are the same property, and what do they cost today?".
  3. Given the hotel-owner segment renders, when I read it, then the ask is "Where am I being undercut this weekend, by which OTA, and by how much?" and the body promises a comp set watched "across the major OTAs — plus your own direct rate".
  4. Given the agency segment renders, when I read it, then the ask is "Which hotels near this office meet our policy, and what do they cost?" with the example "4★+, walkable to the client office, refundable".
  5. Given the developer segment renders, when I read it, then the ask is "Give my agent hotel search and pricing without building an ingest pipeline first."
  6. Given I click a segment, when the interaction resolves, then there is no segment-specific destination — every path leads to the same Google sign-in.
Implementation notes

LandingUseCases in components/landing/; the same four segments are the natural input to a role-based onboarding checklist (see ONBD-13), which would make the segmentation survive the sign-in boundary.

Edge cases & risks

Criterion 6 is the conversion problem: the page segments carefully and then throws the segmentation away at the door, so a hotel owner and an OTA engineer get an identical empty /search screen. The "walkable to the client office, refundable" example implies policy filters (walkability, refundability) that the filter catalogue in filterDefs.ts does not have.

MKTG-08

Close with a final CTA and a footer that signals a real company

Partial
ProspectShould
As a Prospect, I want the page to end with a clear next step and evidence there is a real, operating company behind it, so that I am comfortable putting my supply file into it.
Acceptance criteria
  1. Given LandingFinalCta renders, when the section loads, then it repeats the primary sign-in CTA and the sales contact.
  2. Given LandingFooter renders, when it loads, then it links Hotel details, Metasearch prices, Direct pricing, AI assistant, MCP server, MCP setup, API reference, Use cases, Hotel owners and About Ubio Ltd. (UBIO_ABOUT_URL).
  3. Given the footer renders, when I read the identity block, then it states "An AI-native hotel data platform. Built by Ubio Ltd., London." and "© 2026 Ubio Ltd. · Made in London".
  4. Given the footer renders, when the version line loads, then it shows the deployed portal version, giving a visible build signal on a public page.
  5. Given the footer renders, when the status line loads, then it always reads "Status: all systems green" regardless of actual platform health.
  6. Given a visitor looks for terms, privacy, a DPA or a security page, when they scan the footer, then none are linked.
Implementation notes

LandingFinalCta and LandingFooter in components/landing/; the portal version comes from the same __PORTAL_VERSION__ build define used by ProfileMenu.vue.

Edge cases & risks

Criteria 5 and 6 are the named gaps: a hard-coded "all systems green" is worse than no status line during an incident, and the absence of any legal or privacy link is a blocker for procurement at exactly the OTA and corporate buyers the page targets. Publishing the build version publicly also narrows an attacker's search space.

MKTG-09

Show self-serve pricing on a public route

Partial
ProspectMust
As a Prospect, I want to see what this costs before I create an account, so that I can qualify it against my budget without talking to anyone.
Acceptance criteria
  1. Given I open /pricing, when the page renders, then it states "Four self-serve tiers per product. Higher volumes and custom terms are handled by sales." and "Scale up or down any time."
  2. Given the page renders, when the pricing model is explained, then it says each product is billed on its own — "a flat fee that bundles capacity, then a transparent per-unit rate beyond it. Prices in USD".
  3. Given the page loads public pricing, when getPublicPricing() resolves, then per-product facts are rendered; when it rejects, then the error is logged and the page still renders its static framing.
  4. Given I am not signed in, when I reach the tier detail, then I see "Sign in to see all tiers" instead of the full tier ladder.
  5. Given I sign in from /pricing, when I land in the portal, then I see the real ladder in BillingPlans.vueexperience, consume, deliver, grow, more per product with $X.XX/mo. or /yr. prices.
  6. Given the public page and GET /billing/plans?product= disagree, when a price changes, then nothing reconciles them automatically.
Implementation notes

The live /pricing route calls getPublicPricing() and renders per-product fact blocks; the authenticated equivalent is BillingPlans.vue over GET /billing/plans?product= for smartFeed (monthly), metasearch (monthly) and richContent (yearly), with Contact Us on sales-only tiers linking to mailto:hello@ubio.ai?subject=<Product> Plan.

Edge cases & risks

"Prices in USD — no sign-in needed to see them" sits next to "Sign in to see all tiers", so the page simultaneously promises and withholds transparency; a prospect who cannot see the tier that fits them will not create an account to find out. The portal's own currency default is GBP (GET /metasearch currency default GBP), so a USD-only pricing page adds friction for the UK and EU buyers the London positioning attracts.

MKTG-10

Convert through Google sign-in as the only door

Partial
ProspectMust
As a Prospect, I want to get into the product in one click, so that I can evaluate it during the session in which I found it.
Acceptance criteria
  1. Given I click any landing CTA, when the handler runs, then components/landing/useSignIn.ts starts the same OAuth flow as AppLogin.vue's "Continue with Google", with scope email profile and redirect ${FRONTEND_URL}/googleAuthCallback.
  2. Given the callback resolves, when POST /auth/google exchanges the code, then a User is created on first login, pending invitations for that email are processed, and if I have no organisation one is auto-created as "<name>'s Organization" at tier TRIAL.
  3. Given the OAuth provider returns an error, when GoogleAuthCallback.vue parses the Authentication failed: {json} payload, then an error state is shown rather than a blank screen.
  4. Given I do not have a Google account, when I try to sign up, then there is no alternative path — no email/password, no other SSO, no email verification and no password reset exist in the codebase.
  5. Given my company blocks Google OAuth, when I click any CTA, then I am blocked from evaluating the product entirely.
Implementation notes

authProvider: 'google' is set in Config.ts; the scaffold AuthService drives the flow; app/public/silent-check-sso.html and AC_PUBLIC_ENV_KEYCLOAK_URL=https://auth.automationcloud.net are legacy Keycloak leftovers unused by the Google path.

Edge cases & risks

Criteria 4 and 5 are the named gaps and they are commercially material: the landing page's primary named segments — OTAs, aggregators, corporate travel buyers — are exactly the organisations most likely to run Microsoft identity and block consumer Google sign-in. Auto-creating a personal org per Google account also fragments a company across several orgs unless invitations are used from the start.

MKTG-11

Offer email-based signup alongside Google

Proposed
Corporate travel buyerMust
As a Corporate travel buyer whose company does not use Google Workspace, I want to create an account with my work email, so that I can evaluate Hotel Universe at all.
Acceptance criteria
  1. Given I am on the landing page, when the sign-in options render, then an email option is shown alongside "Continue with Google".
  2. Given I submit a work email, when the request is accepted, then a verification email is sent and no session is issued until the address is verified.
  3. Given I verify, when the account is created, then the same first-login path runs as for Google: user record, pending-invitation processing, and an auto-created TRIAL organisation.
  4. Given the email already exists as a Google-linked account, when I try to register it, then I am told to continue with Google rather than creating a duplicate user.
  5. Given I forget my password, when I request a reset, then a time-limited reset link is sent and used once.
  6. Given I sign up with a disposable or personal-webmail domain, when the account is created, then it is flagged for sales qualification rather than blocked.
Implementation notes

users already carries googleId? as optional and email as the identity key, so a credential record can be added without reshaping the collection; email delivery reuses the SendGrid sender already used by sendInvitationEmail; sessions continue to be the existing JWT signed with JWT_PRIVATE_KEY and JWT_TOKEN_EXPIRATION_SECONDS.

Edge cases & risks

Introducing passwords brings the whole credential-security surface (hashing, rate limiting on login, breach response) into a codebase that currently rate-limits only GET /metasearch. The email-domain super-user check isUbee() matching @ub.io becomes a real privilege-escalation risk the moment an unverified email can be registered — verification must land before, not after.

MKTG-12

Publish real prices without a sign-in wall

Proposed
ProspectMust
As a Prospect, I want the actual per-tier numbers for each product on a public page, so that I can build a business case before I involve anyone.
Acceptance criteria
  1. Given I open /pricing unauthenticated, when the page renders, then all four self-serve tiers per product are shown with their real included capacity and per-unit overage rates, for smartFeed, metasearch and richContent.
  2. Given a tier is sales-only, when it renders, then it is clearly marked "Contact sales" rather than hidden behind sign-in.
  3. Given prices are published, when GET /billing/plans?product= changes, then the public page reflects the change without a separate content edit.
  4. Given I view from outside the US, when prices render, then the currency is stated explicitly and a note explains billing currency versus display currency.
  5. Given the pricing service is unavailable, when the page loads, then a cached snapshot is shown with an "as of" date rather than an empty page.
  6. Given a tier's quota semantics are complex (rich content is per hotel per year, metasearch is prepaid with top-ups), when the tier renders, then the unit is spelled out so a reader cannot mistake one product's unit for another's.
Implementation notes

Would promote the existing getPublicPricing() endpoint to serve the full ladder that BillingPlans.vue already renders, reusing the formatting helpers in utils/billingFormatters.ts (prettifyPlan, formatTierLabel, formatSubscriptionUsageLine/OverageLine, formatMetasearchPrepaidLine, formatCurrency).

Edge cases & risks

Publishing per-unit rates constrains discounting and invites competitor price-matching, which is presumably why the wall exists; the mitigation is publishing list prices and keeping negotiated terms in the sales-only tiers. The rich-content over-limit charge logic (computeRichContentOverLimit) is subtle enough that a simplified public rendering could be read as a binding quote.

MKTG-13

Let visitors try the product on sample data before signing up

Proposed
ProspectShould
As a Prospect, I want to run a real search and see a real hotel record without an account, so that I can judge data quality rather than trusting a marketing card.
Acceptance criteria
  1. Given I open the public sandbox, when it loads, then I can run a name/city search over a fixed sample corpus of a few hundred canonical hotels and see the same HotelResultsTable columns the portal uses — Name, Location, Star rating, User rating and the six data-availability icons.
  2. Given I open a sample hotel, when the profile renders, then I see the Overview, Amenities, Photos and Ratings sections populated from the real canonical schema, with a watermark marking it as sample data.
  3. Given I attempt anything outside the sample set — a different city, an export, a price lookup — when the action fires, then I am shown the sign-in CTA with the intended action preserved as redirectUrl.
  4. Given the sandbox is unauthenticated, when requests are made, then they hit a dedicated read-only endpoint with its own rate limit and never touch GET /hotels/{huId} or the accessLogs quota counters.
  5. Given a sample record contains a real property's name and photos, when it is served, then image URLs go through the existing images.automation.cloud/img?hash=… indirection rather than exposing source URLs.
  6. Given the sandbox is abused as a free API, when a client exceeds the sandbox rate limit, then it returns 429 with Retry-After like GET /metasearch does.
Implementation notes

Would reuse HotelsTable.vue / HotelResultsTable.vue and the hotel detail components against a frozen sample slice of canonicalData6; rate limiting reuses rate-limiter-flexible with Redis (REDIS_URL), which is already wired but applied to one route only.

Edge cases & risks

An unauthenticated data surface is exactly the class of endpoint the platform has already got wrong four times (/scrapers/*, POST /sources, /img/resize, POST /invitations/{id}/accept are all unauthenticated), so this needs a hard scope boundary and its own tests. Publishing real hotel content publicly also has licensing implications with the underlying sources.

MKTG-14

Publish a coverage explorer so buyers can check their market first

Proposed
OTA supply managerShould
As an OTA supply manager, I want to see how many hotels you have in the countries I operate in, so that I can rule the platform in or out before spending time on a trial.
Acceptance criteria
  1. Given I open the public coverage page, when it loads, then it shows hotel counts by country (ISO alpha-2), sourced from the same location.countryCode field the search index uses.
  2. Given I select a country, when the breakdown renders, then it shows how many properties have location, rating, features, reviews, images and rooms available, matching the six HotelMeta availability flags.
  3. Given I select a country, when direct-pricing coverage is shown, then it is reported separately, because direct ARI coverage is far narrower than content coverage.
  4. Given counts are expensive to compute, when the page loads, then it serves a precomputed daily snapshot with an "as of" timestamp rather than aggregating live.
  5. Given a country has fewer than a disclosure threshold of properties, when it renders, then it is bucketed rather than shown as an exact number.
  6. Given I want detail beyond country level, when I ask, then the page routes me to sign-in or to sales rather than exposing city-level supply intelligence for free.
Implementation notes

Counts can be precomputed from canonicalData6 using the existing location.countryCode token index and the meta.*.available flags that are already part of the atlas_custom_search_10 index; country names and metadata come from Countries.ts.

Edge cases & risks

Coverage numbers are a competitive disclosure — publishing per-country counts tells competitors exactly where the graph is thin. Availability flags are sticky for images and reviews in computeMeta, so a "has images" count can overstate current freshness.

MKTG-15

Make the marketing and docs surface indexable

Proposed
Ubio sales repMust
As a Ubio sales rep, I want the site to rank for the questions our buyers actually type, so that pipeline arrives without paid acquisition.
Acceptance criteria
  1. Given the live site currently serves a noindex robots meta, when the indexing change ships, then /landing, /pricing and the docs host are indexable while authenticated portal routes remain excluded.
  2. Given a crawler fetches the landing page, when it parses the response, then title, description, canonical URL, Open Graph and structured data (Organization, Product, FAQ) are present.
  3. Given comparison and concept pages are published (hotel content APIs, OTA rate comparison, direct ARI, hotel entity resolution), when they render, then each has a single canonical URL and links to the relevant product card and docs page.
  4. Given the portal is a single-page app behind auth, when a crawler requests an authenticated route, then it is disallowed in robots.txt and returns no indexable content.
  5. Given a page is removed or renamed, when it is requested, then a 301 is served rather than falling through the catch-all /:path(.) redirect to Home.
  6. Given a page is indexed, when a visitor arrives from search, then the CTA and any pre-filled sales context match the page they landed on, not a generic hero.
Implementation notes

The blocking change is removing the noindex meta from the public routes; the SPA catch-all in RouterManager.ts currently redirects every unknown path to Home, which makes proper 3xx handling a server-level concern rather than a router one. Docs already live on a separate host (https://docs.hotel-universe.travel/guide/), which is the easiest surface to open up first.

Edge cases & risks

Removing noindex exposes every marketing claim to permanent public record — including the webhook and scoped-key claims that are not yet true — so the copy audit has to precede the indexing change. Client-rendered Vue content indexes unevenly without SSR or prerendering, so the first attempt may index the shell and not the copy.

MKTG-16

Publish proof: customers, logos and case studies

Proposed
ProspectShould
As a Prospect, I want evidence that other companies like mine use this, so that I can justify the risk of routing our supply file through a new vendor.
Acceptance criteria
  1. Given the landing page renders, when the proof section loads, then it shows named customer logos with written permission recorded per logo.
  2. Given a case study is published, when it renders, then it states the segment (one of the four use-case segments), the problem, the products used and at least one quantified outcome.
  3. Given a customer has not consented to naming, when their story is published, then it is anonymised by segment and region and no identifying detail is included.
  4. Given a case study references a capability, when it is written, then that capability must exist in the product — no case study may imply the webhook diff push until it ships.
  5. Given a logo permission is withdrawn, when the change is made, then the logo can be removed without a code deploy.
  6. Given there are no case studies yet, when the section would render empty, then it is hidden rather than showing placeholder logos.
Implementation notes

Slots into views/Landing.vue's section sequence between LandingUseCases and LandingFinalCta, and reuses the existing use-case segmentation so a visitor sees proof for their own segment first.

Edge cases & risks

For OTA and aggregator customers, being named as a Hotel Universe user reveals part of their supply strategy, so consent will be hard to obtain and easy to lose — criterion 5 exists for that reason. Fabricated or anonymous-only proof is a known trust-destroyer with technical buyers.

MKTG-17

Replace the hard-coded status line with a real status page

Proposed
DeveloperMust
As a Developer, I want a public status page with real component health and incident history, so that I can verify a platform claim before I depend on it and diagnose failures after I do.
Acceptance criteria
  1. Given I open the public status page, when it loads, then it shows per-component health for API, search, metasearch, direct pricing, exports and ingest, rather than one aggregate claim.
  2. Given the API is healthy, when the page polls, then it reflects GET /status ({version, service, timestamp}) plus component-level checks, not just process liveness.
  3. Given metasearch is degraded because METASEARCH_SERVICE_URL is failing or slow, when the page renders, then that component shows degraded and the overall banner is not green.
  4. Given an incident is resolved, when the history renders, then it retains a dated entry with duration and a short cause description.
  5. Given the status backend is itself down, when the landing footer requests status, then it shows "status unavailable" rather than defaulting to "all systems green".
  6. Given a 90-day window is requested, when uptime is shown, then per-component percentages are published for that window.
Implementation notes

Replaces the literal "Status: all systems green" string in LandingFooter; the only existing health signal is the unauthenticated GET /status, so component checks would need adding for the metasearch proxy, the scraping service and GCS export writes.

Edge cases & risks

Scraper degradation is the hardest component to represent honestly — a failing TripAdvisor task does not break any request, it just makes data stale, so "green" would be technically true and practically misleading. Hosting the status page on the same infrastructure as the product is the classic failure: an outage takes the status page with it.

MKTG-18

Publish a security and compliance trust centre

Proposed
Corporate travel buyerMust
As a Corporate travel buyer, I want your security posture, data-processing terms and sub-processor list in one place, so that I can get this through procurement without a two-month questionnaire cycle.
Acceptance criteria
  1. Given I open the trust centre, when it loads, then it publishes the current security certification position (for example SOC 2 status or a stated roadmap), the privacy policy, terms of service and a downloadable DPA template.
  2. Given I need the sub-processor list, when it renders, then it names the real dependencies — Google Cloud Storage for exports (europe-west1), MongoDB Atlas, Stripe, SendGrid, Google OAuth and the proxy providers used by the scrapers — with their processing purpose and region.
  3. Given I ask about data retention, when the page renders, then it states the actual behaviour: access logs retained one year by TTL, export objects expiring after 30 days with 7-day soft delete, signed download URLs valid 15 minutes, invitations expiring after 7 days.
  4. Given I ask about personal data, when the page renders, then it states that review exports strip the reviewer field, and describes what the AI assistant transcript escalation transmits.
  5. Given a sub-processor changes, when the list is updated, then subscribed customers are notified before the change takes effect.
  6. Given a claim cannot yet be substantiated, when the page is drafted, then it states the roadmap position rather than implying certification.
Implementation notes

Every retention fact above is already true in the platform (accessLogs time-series TTL 1 year, GCS lifecycle delete 30 days with soft-delete 7 days, v4 signed URLs at 15 minutes, invitations.expiresAt at 7 days, HotelReviewExport stripping reviewer), so the first version is documentation of existing behaviour rather than new engineering.

Edge cases & risks

Documenting the architecture honestly will surface the known unauthenticated endpoints (/scrapers/*, POST /sources, /img/resize) and the absence of per-user roles — every org member holds identical scopes — which sophisticated buyers will ask about directly. Publishing a trust centre before fixing those turns a quiet defect into a public one.

MKTG-19

Capture and qualify leads instead of relying on mailto

Proposed
Ubio sales repMust
As a Ubio sales rep, I want inbound interest captured as structured, qualified leads, so that I can prioritise and follow up instead of triaging a shared inbox.
Acceptance criteria
  1. Given I click "Talk to sales" or a sales-only tier's "Contact Us", when the interaction starts, then a form is offered (with mailto:hello@ubio.ai retained as a fallback link).
  2. Given I submit the form, when it is accepted, then it captures work email, company, segment (the four use-case segments), products of interest, approximate hotel volume and free text.
  3. Given the lead arrives, when it is routed, then it is scored by segment and volume and assigned, with a stated response-time commitment shown to the submitter.
  4. Given the submitter already has a portal account, when the form is opened while authenticated, then org, tier and current subscriptions are attached automatically.
  5. Given the submission fails, when the error returns, then the typed content is preserved and the mailto: fallback is offered with the content prefilled.
  6. Given a bot submits the form, when it is processed, then it is rejected without a CAPTCHA on the happy path.
Implementation notes

Complements the existing POST /ai/assistant/contact escalation path, which already carries a message, a transcript and the page URL; the sales form would use the same intake with an explicit lead payload. BillingPlans.vue's mailto:hello@ubio.ai?subject=<Product> Plan already knows which product triggered the request, which is the strongest qualification signal available.

Edge cases & risks

Two intake paths (assistant escalation and sales form) will diverge unless they write to one record; the current split between hello@ubio.ai and support@ubio.ai shows how easily that happens. Asking for hotel volume up front deters self-serve buyers who should never have talked to sales at all, so the form must stay optional next to the transparent pricing work.

MKTG-20

Quantify the saving with an ROI calculator

Proposed
Revenue managerCould
As a Revenue manager, I want an interactive estimate of what rate parity monitoring is worth to my property, so that I can put a number in front of my owner rather than a feature list.
Acceptance criteria
  1. Given I open the calculator, when it renders, then I can enter room count, average nightly rate, occupancy and the number of OTAs I distribute through.
  2. Given I enter values, when the result recalculates, then it estimates recoverable revenue from closing observed rate gaps and shows the assumptions used, not just the output number.
  3. Given the calculation uses platform benchmarks, when they are displayed, then their source and date are stated (for example the average undercut magnitude observed across comparable properties).
  4. Given I change a single input, when the result updates, then it does so live without a page reload and the URL captures my inputs so the result is shareable.
  5. Given my inputs imply a very small property, when the result renders, then it recommends the lowest self-serve tier rather than routing me to sales.
  6. Given the result is generated, when I choose to save it, then submitting my email attaches the scenario to a lead record rather than gating the number itself behind the email.
Implementation notes

The underlying maths mirrors what HotelPriceInsights.vue already computes for a real property — gap versus median, "Underpriced vs competitors" date lists with gapAbs/gapPct, weekend premium and volatility — so the calculator is a public, assumption-driven version of a shipped analysis.

Edge cases & risks

ROI calculators are easy to over-claim; if the modelled saving is not reproducible in HotelPriceInsights.vue after the customer signs up, the tool actively damages trust. Criterion 6 matters because email-gating the number is the single most common reason these tools are abandoned.

MKTG-21

Reward referrals from existing customers

Proposed
Organisation adminCould
As an Organisation admin who already gets value from the platform, I want a simple way to refer a peer and get credit, so that recommending it is worth the two minutes it costs me.
Acceptance criteria
  1. Given I open the referral page in settings, when it loads, then I get a unique referral link tied to my organisation.
  2. Given someone signs up through my link, when their org is created, then the referral is attributed and both orgs receive the stated credit once the referred org converts to a paid subscription.
  3. Given the referred person already had an account, when they follow the link, then no attribution is recorded.
  4. Given a referral is self-dealing (same email domain, or the same user creating a second org), when it is evaluated, then it is rejected and flagged.
  5. Given credits are granted, when they are applied, then they appear as a line on the Stripe invoice rather than as an invisible internal adjustment.
  6. Given the programme is withdrawn, when existing referrals are pending, then already-earned credits are honoured.
Implementation notes

Attribution would ride the existing first-login org-creation path (which already handles pending invitations for an email) and be recorded on the organizations document alongside billing; credit application goes through Stripe rather than the internal tier logic, since tier is derived purely from status === 'active' today.

Edge cases & risks

Criterion 4 is essential given anyone with a Google account can create an unlimited number of TRIAL orgs — the auto-created "<name>'s Organization" path makes fake referrals trivial. Because tier mapping ignores plan (all three of pro|pro_plus|ultra grant identical access today), a credit scheme has no natural notion of value tiering until that TODO is resolved.

MKTG-22

Get a developer to their first API response in 60 seconds

Proposed
DeveloperMust
As a Developer, I want a copy-paste path from sign-in to a real API response, so that I can prove the platform works in my stack before I read any documentation.
Acceptance criteria
  1. Given I sign in for the first time and identify as a developer, when the quickstart renders, then step one issues a PAT via POST /tokens and shows the pat_-prefixed secret once with the existing copy button and "Make sure to copy the token now…" warning.
  2. Given the token is issued, when step two renders, then it shows a ready-to-run curl against GET /search with my token already substituted and a live-looking filter (for example country=GB&city=London&stars=4).
  3. Given I run the request, when it succeeds, then the quickstart detects the first hotels:view or search access-log entry for that principal and marks the step complete.
  4. Given my search returns zero hotels because I supplied no filters, when the response comes back empty, then the quickstart explains that an empty query matches nothing by design and suggests a filter.
  5. Given step three renders, when I follow it, then it offers the MCP path — npx @hoteluniverse/mcp with HU_API_KEY — as the alternative to raw HTTP.
  6. Given I exceed my trial hotel allowance during the quickstart, when a 402 is returned, then the quickstart shows the "Upgrade to view. Contact sales at hello@ubio.ai" message inline with a link to /billing.
Implementation notes

Everything needed exists: AccessTokens.vue's generate dialog, PAT scopes (exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view), GET /search with its documented filter set, and accessLogs for completion detection. Criterion 4 covers a real trap — with no clauses at all the search pipeline short-circuits to $match {_id: 'impossible-id'} and returns nothing.

Edge cases & risks

The published OpenAPI spec is stale (v0.27.2, 33 paths, no securitySchemes, no operationIds) and docs/docs.http documents a POST /auth/pat endpoint that does not exist and a locale default of gb where the code uses us — so a developer who leaves the quickstart for the reference docs immediately hits contradictions. PATs never expire, so a quickstart that mints tokens freely accumulates permanent credentials.

ADMN 4 built5 partial12 proposed

Ubio internal admin & operations tooling

Internal tooling today is a thin layer of route-meta and email-domain checks bolted onto the customer portal: requiresUbioAccess guards two manual IBE-matching screens, isUbee() / isUbioUser() grant super-user powers to anyone whose email contains @ub.io or ubio.ai, GET /orgs lists every organisation, and /stats skips the org-membership check for those users. There is no admin console, no impersonation, no audit trail, no feature-flag system and no data-ops queue — gating is tier plus route meta plus email domain, and nothing else. This epic covers what internal users can do today and the operations, support and revenue tooling Ubio needs to run the platform at more than a handful of accounts.

ADMN-01

Guard internal screens behind a route-meta check

Partial
Ubio adminMust
As a Ubio admin, I want internal screens hidden from customers by a router guard, so that operations tooling is not reachable by anyone who guesses the URL.
Acceptance criteria
  1. Given a route declares meta.requiresUbioAccess: true, when router.beforeEach runs, then await access.ensureUbioAccessResolved() must return true or I am redirected to next({ name: 'Home' }).
  2. Given I am a customer user, when I type /hotels/:huid/manual-ibe-matching or /lists/:listId/manual-ibe-matching directly, then I am bounced to Home without the screen rendering.
  3. Given the access check has not resolved yet, when the guard runs, then it awaits resolution rather than allowing the navigation optimistically.
  4. Given the guard passes, when the screen loads, then its API calls to /private/direct-pricing/* are the real authorisation boundary — the router guard alone is presentation-level.
  5. Given the live portal's /admin route exists, when it is guarded, then it uses the same requiresUbioAccess meta, but the guard is the only protection the client applies.
Implementation notes

Guard rule 5 in app/managers/RouterManager.ts; resolution via AccessManager.ensureUbioAccessResolved() / isUbioUser(). ApiManager calls an /admin endpoint that is not present in the local API clone, so the admin surface is live-only.

Edge cases & risks

The named gap: a client-side router guard is not access control. If any /private/direct-pricing/ endpoint trusts the caller the way POST /sources and /scrapers/ do (both entirely unauthenticated in the clone), the internal surface is reachable by anyone. There is no negative test asserting a customer JWT is rejected by the private endpoints.

ADMN-02

Identify internal users by email domain

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want internal staff recognised automatically, so that support and operations features light up without a separate provisioning step.
Acceptance criteria
  1. Given my email contains @ub.io, when isUbee() is evaluated server-side, then I am treated as an internal super-user for GET /orgs and the /stats org-membership bypass.
  2. Given my email domain is ub.io or ubio.ai, when AccessManager.isUbioUser() is evaluated in the portal, then internal UI is revealed.
  3. Given the two checks disagree (portal accepts ubio.ai, the API accepts only @ub.io), when a ubio.ai user opens an internal screen, then the UI appears but the server-side super-user paths do not apply.
  4. Given an internal user leaves the company, when their Google account is disabled, then they lose access only because OAuth fails — there is no internal role to revoke.
  5. Given an external contractor needs data-ops access, when they are onboarded, then there is no way to grant it without issuing them a Ubio email address.
Implementation notes

Server-side isUbee() does a substring check for @ub.io on the user's email; the portal's AccessManager.isUbioUser() accepts both ub.io and ubio.ai. There is no role field anywhere — users has no roles and organizations has only memberIds.

Edge cases & risks

A substring check on @ub.io is weak: it is satisfied by addresses ending in domains that merely contain that string, and it grants read access to every organisation on the platform. Criteria 3–5 are the named gaps — inconsistent domains, no revocation, no least privilege, and no separation between "can see all orgs" and "can change a customer's data".

ADMN-03

List every organisation on the platform

Built
Ubio support agentMust
As a Ubio support agent, I want to see all customer organisations, so that I can find the account a caller is talking about.
Acceptance criteria
  1. Given I am an internal user, when the portal calls getAllOrganizations(), then GET /orgs returns every organisation rather than only my memberships.
  2. Given I am a customer user, when the same endpoint is called, then it is refused because the ubio-only check fails.
  3. Given the response renders, when I read a row, then I get the organisation id, name and tier — enough to identify but not enough to diagnose.
  4. Given there are thousands of organisations, when the endpoint is called, then it returns them without pagination or search parameters.
  5. Given I need to find an org by a member's email or by Stripe customer id, when I search, then there is no way to do so through this endpoint.
Implementation notes

GET /orgs in the API, gated by isUbee(); ApiManager.getAllOrganizations() in the portal. The customer-facing equivalent is GET /users/me/orgs, which returns [{id, name, tier, billing?}].

Edge cases & risks

Criteria 4 and 5 are the operational limits: an unpaginated full-collection listing is both a performance and a usability problem, and without email or Stripe-id lookup, finding the right org for an inbound support call means eyeballing a long list of names. The endpoint is also the entire "admin data model" today.

ADMN-04

Inspect any organisation's usage from the Usage screen

Built
Ubio support agentMust
As a Ubio support agent, I want to pull up any customer's usage by action and date range, so that I can answer "why am I being told I hit a limit" without asking engineering.
Acceptance criteria
  1. Given I am an internal user, when Usage.vue loads, then the organisation selector is populated from getAllOrganizations() rather than getOrganizations().
  2. Given I pick an organisation and a date range, when the query runs, then GET /stats?orgId&startDate&endDate returns [{action, uniqueHotelCount}] and UsageTable.vue maps the action codes to descriptions.
  3. Given the action list renders, when I read it, then it covers lists:create, metasearch:view, hotels:view, reviews:view, export:create:full|preview|reviews and export:download:full|preview|reviews.
  4. Given the default range is the last 30 days, when I need all-time trial consumption, then I must widen the range manually, because ACCESS_TIER_TRIAL_LIMIT is counted all-time while this screen is range-scoped.
  5. Given the organisation has no logged activity in the range, when the results render, then the "No usage data found" empty state is shown rather than an error.
  6. Given I switch organisation mid-request, when the earlier response arrives, then it must not overwrite the newer selection's results.
Implementation notes

views/Usage.vue with UsageTable.vue; data comes from the accessLogs MongoDB time-series collection (timeField: time, metaField: meta, hour granularity, one-year TTL) aggregated as distinct huId per action.

Edge cases & risks

Criterion 4 is a genuine support trap — the screen shows a windowed count while the trial limit is enforced on an all-time distinct-hotel count, so support can easily quote a number that does not explain the 402 the customer is seeing. The one-year TTL also means older disputes cannot be reconstructed.

ADMN-05

Bypass org membership when reading stats

Partial
Ubio finance opsShould
As a Ubio finance ops analyst, I want to read any organisation's usage without joining it, so that I can reconcile invoices without polluting customer member lists.
Acceptance criteria
  1. Given I am an internal user, when I call GET /stats/ with an orgId I am not a member of, then the org-membership check is bypassed and the data is returned.
  2. Given I am a customer user, when I request another org's stats, then the request is refused.
  3. Given the bypass is used, when the request completes, then nothing records that an internal user read a customer's usage.
  4. Given usage is needed for billing reconciliation, when I read it, then the numbers are distinct-hotel counts per action, not billable units — no usage is metered to Stripe.
  5. Given the bypass exists only on /stats, when I need the same view of exports or list contents, then no equivalent internal read path exists.
Implementation notes

Implemented directly in the /stats handler as an isUbee() branch around the membership assertion; the underlying aggregation is the same one customers use.

Edge cases & risks

Criterion 3 is the audit gap — a super-user can read every customer's activity with no trace, which will fail any SOC 2 style review and is the single most important thing to fix before publishing a trust centre. Criterion 4 matters commercially: with no usage metering reported to Stripe, finance reconciliation is manual against internally-enforced quotas.

ADMN-06

Match one hotel to a Smart Feed property by hand

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to link, reject or create the Smart Feed record for a specific huId, so that direct pricing can be enabled for a customer's property that automatic matching could not resolve.
Acceptance criteria
  1. Given I open /hotels/:huid/manual-ibe-matching, when the screen loads, then I can choose Link existing or Create in Smart Feed.
  2. Given I choose Link existing and enter a Smart Feed hotel id, when I submit, then linkDirectPricingManual(huId, sfHotelId) posts to POST /private/direct-pricing/link-manual and the hotel becomes linkable for direct pricing.
  3. Given the candidate is genuinely not the same property, when I choose Reject, then rejectDirectPricingLink(huId) posts to POST /private/direct-pricing/reject-link so it is not re-suggested.
  4. Given I choose Create in Smart Feed, when I pick an IBE from getDirectPricingIbes() and supply a PoS URL, then importDirectPricingHotel(...) posts to POST /private/direct-pricing/import-hotel.
  5. Given I enter a Smart Feed id that does not exist or is already linked to another huId, when I submit, then an error is surfaced and no link is created.
  6. Given a link is created, when it is recorded, then there is no stored record of who linked it or why.
Implementation notes

views/ManualIbeMatching.vue, guarded by meta.requiresUbioAccess; the three /private/direct-pricing/* endpoints are the only internal-namespaced API surface the portal calls.

Edge cases & risks

A wrong manual link is worse than no link: the customer will be shown another property's direct rates as their own, and criterion 6 means there is no way to find out who made the mistake or reverse it in bulk. The screen offers no side-by-side comparison of the two properties' name, address and coordinates to support the decision.

ADMN-07

Clear a whole list's unlinked hotels through a CSV loop

Built
Ubio data ops engineerShould
As a Ubio data ops engineer, I want to work through a customer list's unlinked hotels in bulk, so that onboarding a large portfolio for direct pricing does not mean hundreds of single-hotel screens.
Acceptance criteria
  1. Given I open /lists/:listId/manual-ibe-matching, when the screen loads, then it lists the list's unlinked hotels from GET /lists/:id/direct-pricing/unlinked with offset/limit paging.
  2. Given I click "Download CSV", when the file is produced by GET /lists/:id/direct-pricing/unlinked/export, then it contains the unlinked hotels in a form I can fill in offline.
  3. Given I upload a completed file, when POST /lists/:id/direct-pricing/link-csv returns, then the result reports {linked, failed, errors} and the failures are shown row by row.
  4. Given every hotel in the list is linked, when the screen renders, then the "All hotels matched" empty state is shown.
  5. Given my uploaded CSV has malformed or unknown Smart Feed ids, when it is processed, then those rows appear in errors and the valid rows are still linked.
  6. Given the list is large, when I download and re-upload, then nothing prevents a second operator working the same list concurrently and producing conflicting links.
Implementation notes

views/ListManualIbeMatching.vue with downloadListUnlinkedCsv(listId) and uploadListIbeLinkCsv(listId, file) in ApiManager.

Edge cases & risks

The download/upload loop has no locking or versioning (criterion 6), and no dry-run mode — an operator cannot preview what a file would do before it does it. Because the CSV round-trips through a spreadsheet, id columns are vulnerable to the usual auto-formatting corruption.

ADMN-08

Trigger direct pricing for a customer list from inside the list view

Partial
Ubio support agentShould
As a Ubio support agent, I want to request direct pricing for a customer's list while I am looking at it, so that an onboarding request does not need a separate internal tool.
Acceptance criteria
  1. Given I am an internal user viewing a list's Confirmed Hotels tab, when ListHeader.vue renders, then a "Request direct pricing" button is shown.
  2. Given I am a customer user, when the same header renders, then the button is absent, because it is gated on access.isUbioUser().
  3. Given I click the button, when the confirm dialog is accepted, then enableDirectPricingByList(listId) posts to POST /direct-pricing/enable and returns a {jobId}.
  4. Given the job is running, when I return to DirectPriceLists.vue, then the list's coverage tag shows Processing until it resolves to Full coverage or Partial.
  5. Given I am on a different tab of the list, when the header renders, then the button is not shown, so the action is tab-dependent as well as role-dependent.
  6. Given the action has cost implications for the customer, when I trigger it, then nothing records that a Ubio user rather than the customer initiated it.
Implementation notes

ListHeader.vue renders the button only when access.isUbioUser() and the Confirmed-Hotels tab is active; coverage states in DirectPriceLists.vue are Processing, Unknown, No hotels, None active, Full coverage and Partial with X of Y active — Z to enable.

Edge cases & risks

Criterion 6 is the named gap and it is a billing risk: an internal user can enable a chargeable capability on a customer's account with no attribution. Hiding a staff-only control inside a customer screen also means every change to ListHeader.vue risks exposing or breaking it.

ADMN-09

Retire or finish the mock recommendations screen

Partial
Ubio platform engineerCould
As a Ubio platform engineer, I want the hidden mock screen either wired to real data or removed, so that we do not ship a route that renders invented recommendations to anyone who finds it.
Acceptance criteria
  1. Given /ai/recommendations is requested by any authenticated user, when AiRecommendations.vue renders, then it currently shows a hard-coded recommendations array with no API calls.
  2. Given the route is not in NavigationManager.getNavItems(), when a user browses the portal, then they never see a link to it.
  3. Given a customer discovers the URL, when the page renders, then nothing marks the content as sample data.
  4. Given the screen is kept, when it is completed, then it must source recommendations from real signals (list contents, price insights, competitor sets) rather than a literal array.
  5. Given the screen is removed, when the route is deleted, then the catch-all /:path(.) redirect sends any old link to Home without an error.
Implementation notes

views/AiRecommendations.vue is registered in RouterManager.ts under the authenticated shell but omitted from NavigationManager's nav items (Search; group Products = Rich Content, Metasearch, Direct Hotel Rates; external Docs).

Edge cases & risks

Unlinked demo routes are how fabricated data reaches a customer screenshot. Because the route carries no requiresUbioAccess meta, it is not even internal-only — it is simply unlisted, which is not a control.

ADMN-10

Build a real admin console with org search and detail

Proposed
Ubio support agentMust
As a Ubio support agent, I want one internal console where I can find an organisation and see everything about it, so that I can resolve a ticket without reading the database.
Acceptance criteria
  1. Given I open the admin console, when I search, then I can find an organisation by name, id, member email or Stripe customer id.
  2. Given I open an organisation, when the detail renders, then it shows tier, members (memberIds), created date and creator, active subscriptions per product (smartFeed, metasearch, richContent) with tier and renewal date, PAT count, list count and rich-content subscribed-hotel count.
  3. Given the org has billing, when the detail renders, then it shows stripeCustomerId, stripeSubscriptionId, stripeStatus and stripePlanName with a deep link into Stripe.
  4. Given I view the org, when the page loads, then all reads are logged with my identity and the org id.
  5. Given I lack the required internal role, when I open the console, then I am refused server-side, not merely redirected by the router.
  6. Given the platform has many organisations, when I search, then results are paginated and the query is served by an index rather than a full scan.
Implementation notes

Replaces the unpaginated GET /orgs with a searchable admin API over organizations, users, accessTokens, lists and exports. Criterion 6 matters because the Stripe webhook already performs a full-collection scan (findAll().find(...)) to map a customer back to an org — the same anti-pattern must not be repeated in search.

Edge cases & risks

An admin console concentrates every customer's commercial data in one screen, so it must land together with the internal role (ADMN-16) and the audit trail (ADMN-17) rather than after them. Deep-linking to Stripe means a second authorisation boundary staff must hold separately.

ADMN-11

Override tier, grant quota and extend trials

Proposed
Ubio sales repMust
As a Ubio sales rep, I want to extend a trial or grant temporary capacity from the console, so that I can unblock an evaluation in minutes instead of asking engineering to edit a record.
Acceptance criteria
  1. Given an org is on TRIAL, when I grant an extension, then its hotel-view allowance is raised above ACCESS_TIER_TRIAL_LIMIT (default 100) for a stated expiry date rather than permanently.
  2. Given I grant an override, when it is saved, then it records who granted it, why, and when it expires, and it is visible on the org detail.
  3. Given an override expires, when the next quota check runs, then the org reverts to its subscription-derived entitlement with no manual cleanup.
  4. Given an org has an active Stripe subscription, when I attempt a tier override, then I am warned that tier is derived from status === 'active' and the webhook will overwrite a manual tier change.
  5. Given I grant metasearch capacity, when it is applied, then it uses the same allowance model as the prepaid top-up path so the customer's usage lines stay consistent.
  6. Given I lack the grant permission, when I attempt an override, then it is refused and the attempt is logged.
Implementation notes

Entitlements would move from the derived AccessTier (trial | full, plus the portal's paid) to an explicit override record consulted by AccessTierService; ACCESS_TIER_TRIAL_LIMIT is currently a single env-level default with no per-org variation.

Edge cases & risks

Criterion 4 is a real collision — the webhook maps status === 'active' to FULL and everything else to TRIAL, so any manual tier write is one subscription event away from being silently reverted. Overrides without expiry become permanent free access nobody remembers granting, which is why criterion 3 is mandatory rather than optional.

ADMN-12

Impersonate a customer with consent and a full audit trail

Proposed
Ubio support agentShould
As a Ubio support agent, I want to see the portal exactly as a specific customer sees it, so that I can reproduce a tier-gating or empty-state problem instead of guessing from a screenshot.
Acceptance criteria
  1. Given I start an impersonation session, when it begins, then it is time-boxed, read-only by default, and requires a stated reason and an existing support reference.
  2. Given impersonation is active, when any screen renders, then a persistent banner states whose account is being viewed and offers an immediate exit.
  3. Given impersonation is active, when I attempt a write (create a list, subscribe hotels, change billing, delete anything), then it is blocked unless write mode was explicitly and separately authorised.
  4. Given the customer's org requires consent, when I request impersonation, then an org admin must approve it, or the session is refused.
  5. Given the session ends, when it is closed, then every request made during it is recorded against my identity, not the customer's.
  6. Given an impersonated session's telemetry would otherwise pollute analytics, when events are emitted, then they are tagged and excluded from portal-events product metrics.
Implementation notes

Would extend the existing token model — the JWT already carries a principal and an org, and POST /auth/switch-org already mints a new token for a different org — with an impersonation claim that the API enforces, rather than reusing the customer's own token.

Edge cases & risks

Impersonation is the highest-risk internal feature on this list: without criterion 5, customer-attributed writes become indistinguishable from staff writes, and accessLogs rows would count against the customer's own quota. The current email-domain super-user check is far too coarse a foundation, so this depends on ADMN-16.

ADMN-13

Show support a customer's recent errors and rate-limit hits

Proposed
Ubio support agentMust
As a Ubio support agent, I want to see the errors a customer actually hit in the last few hours, so that "it isn't working" becomes a specific, answerable question.
Acceptance criteria
  1. Given I open an organisation in the console, when the diagnostics tab renders, then it lists recent 4xx and 5xx responses for that org's principals with endpoint, status, timestamp and principal type (user JWT versus pat_ service account).
  2. Given the customer hit GET /metasearch rate limiting, when the tab renders, then 429s are shown with the configured window (RATE_LIMIT_REQUESTS default 100 per RATE_LIMIT_DURATION_SECONDS 60, keyed by principal) so I can explain the limit precisely.
  3. Given the customer hit a 402, when it renders, then the tab distinguishes the trial hotel limit, the reviews/metasearch tier gate and the metasearch_topup_required branch, because all three surface differently in the portal.
  4. Given a list or export failed, when the tab renders, then it shows the stored error text from the lists or exports record, not just the generic customer-facing message.
  5. Given no errors occurred, when the tab renders, then it says so explicitly rather than showing an empty table.
  6. Given the diagnostics contain hotel ids and list names, when I view them, then the access is logged like any other customer-data read.
Implementation notes

The accessLogs time-series already records successful actions per principal and org; error and rate-limit visibility would need the API to record refusals too. lists.error and exports.error are already persisted and are currently never shown to anyone, since customers see the fixed string "Export failed. Please try again later or contact support if the problem persists."

Edge cases & risks

Rate limiting is Redis-backed when REDIS_URL is set and in-memory otherwise, so in a multi-instance deployment without Redis the effective limit differs per instance and a support explanation based on the configured numbers would be wrong. Recording every refusal adds write volume to a hot path.

ADMN-14

Trigger a re-scrape for a single hotel on demand

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to force a fresh scrape of one property, so that I can fix a customer-reported data error without waiting for the 30-day staleness window.
Acceptance criteria
  1. Given I open a hotel in the admin console, when I trigger a re-scrape, then I can select which sources to refresh (GoogleHotels, Booking hotel, Booking rooms, TripAdvisor) based on which refs exist on the record.
  2. Given a scrape is triggered, when it runs, then scrapingStatuses moves the relevant entry to scraping and then to success or error, and the console reflects the transition.
  3. Given the scrape succeeds, when HotelDataUpdateService.execute runs, then the canonical record is recomputed with combineData honouring the DS_NAMES priority order and _dsNames provenance.
  4. Given the required ref is missing for a source, when I try to select it, then it is disabled with an explanation rather than failing at runtime.
  5. Given the scrape errors, when the status updates, then the error text is shown in the console and the previous canonical data is left intact.
  6. Given re-scrapes are triggered repeatedly, when the rate exceeds a threshold, then further manual triggers for the same hotel are refused to protect the shared proxy pool.
Implementation notes

The scraper classes are already registered in the API app to power the /scrapers/* routes (GET /scrapers/google, /scrapers/booking, /scrapers/booking/rooms), so the capability exists — it is the authorisation and the console around it that do not. Batch scraping uses batchLimit = 100, concurrency = 50 and staleTimeThresholdMs = 30 days in AbstractScrapingService.

Edge cases & risks

The /scrapers/* routes are currently unauthenticated, which makes this feature's first requirement a security fix rather than a UI addition. Manual scrapes bypass the stale-time scheduling that keeps proxy usage predictable, and the Google Travel scraper's spoofed UA and cookies make burst usage a detection risk.

ADMN-15

Work a data-ops queue of failed scrapes and unresolved huIds

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want one prioritised queue of everything the pipeline could not resolve, so that data quality is a worked backlog rather than something we find out about from a customer.
Acceptance criteria
  1. Given the queue loads, when it renders, then it contains source records with huId == null, hotels with a scrapingStatuses entry in error, and list entries stuck in needs_review or unmatched across customer lists.
  2. Given an item is a linking failure, when I open it, then I see the candidate scoring detail from the link pipeline — name similarity, address similarity, postcode match and location match with the 0.7 acceptance threshold — so I can judge the near-miss.
  3. Given items affect a paying customer's list, when the queue is ordered, then those items rank above unaffected ones.
  4. Given I resolve an item by linking it, when I confirm, then the canonical record is recomputed and the customer's list entry resolves without them re-uploading.
  5. Given an item is genuinely unresolvable (a property that no longer exists), when I close it, then it is marked resolved-as-unmatched with a reason and does not return to the queue.
  6. Given the queue is large, when it loads, then it is filtered by source, country, error class and affected org rather than presented as one undifferentiated list.
Implementation notes

All the raw material exists: sourceHotelData5 rows with huId == null (the input to packages/link), the scrapingStatuses collection keyed by refs, and ListReviewQueue.vue's reason codes missing_matching_data, id_lookup_not_found, no_candidates_above_threshold, all_candidates_rejected. The confidence formula is nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3.

Edge cases & risks

The link pipeline's own documented weakness — "123 Main St" versus "124 Main St" scoring too similarly — means human review will regularly be deciding cases the scorer got confidently wrong, so the UI must show the raw address strings, not just the score. AbstractScrapingService also has an open TODO that re-scraping errors should be selected separately, so error records may not be retried at all today.

ADMN-16

Introduce a real internal role instead of an email-domain check

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want internal access driven by an explicit, revocable role, so that super-user power is granted deliberately and can be taken away.
Acceptance criteria
  1. Given internal roles exist, when a staff member is granted one, then it is recorded on their user record with a grantor and a timestamp, replacing the isUbee() substring check on @ub.io.
  2. Given roles are differentiated, when they are assigned, then at minimum support:read, dataops:write, billing:write and admin are separable rather than one all-powerful flag.
  3. Given a role is revoked, when the user's next request is authorised, then internal access is denied immediately without waiting for token expiry (JWTs live up to JWT_TOKEN_EXPIRATION_SECONDS, e.g. 7 days).
  4. Given the portal decides what internal UI to show, when it renders, then it reads the role from the access info rather than parsing the email domain in AccessManager.isUbioUser().
  5. Given an endpoint is internal, when it is called, then it asserts the role server-side; the router's requiresUbioAccess meta remains presentation-only.
  6. Given a staff member has no internal role, when they use the portal, then they see exactly what a customer sees.
Implementation notes

The permission vocabulary already exists as token scopes (orgs:manage, users:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage, exports:manage) but users:manage is declared and never enforced, and there are no per-user roles at all — every org member holds identical scopes. Internal roles would be a separate axis from org scopes.

Edge cases & risks

Criterion 3 is the hard part: with stateless JWTs valid for up to seven days, immediate revocation needs either short-lived internal tokens or a denylist check on every internal request. Splitting one flag into four roles will also break every current call site that assumes isUbioUser() means "can do everything".

ADMN-17

Keep an audit log of every internal action

Proposed
Ubio adminMust
As a Ubio admin, I want an immutable record of what staff did to customer accounts, so that I can answer a customer's "who changed this" and satisfy a security review.
Acceptance criteria
  1. Given any internal action occurs — org read, tier override, quota grant, impersonation start/stop, manual IBE link or reject, Smart Feed import, re-scrape trigger, direct-pricing enable on a customer list — when it completes, then an audit entry records actor, action, target org, target entity, reason and timestamp.
  2. Given an entry is written, when it exists, then it cannot be edited or deleted through the application.
  3. Given I open an organisation, when the audit tab renders, then it shows only entries for that org, newest first, with pagination.
  4. Given a customer asks what Ubio did on their account, when the report is produced, then it can be exported for that org over a date range.
  5. Given an action fails, when the failure occurs, then the attempt is still recorded with its outcome.
  6. Given the audit store is unavailable, when an internal write is attempted, then the write is refused rather than proceeding unlogged.
Implementation notes

The pattern already exists in the platform: accessLogs is a MongoDB time-series collection with timeField: time, metaField: meta, hour granularity and a one-year TTL. An audit collection should use a longer retention than one year and must not share the customer quota semantics of accessLogs.

Edge cases & risks

Criterion 6 will be resisted because it makes the audit store a hard dependency of every internal write, but the alternative — best-effort logging — produces exactly the gaps an auditor looks for. Retention needs a legal decision: audit entries about a deleted org must survive the deletion.

ADMN-18

Flag features per organisation

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want to turn features on for one organisation at a time, so that we can pilot with a design partner without shipping to everyone or forking behaviour on tier.
Acceptance criteria
  1. Given a flag exists, when it is evaluated for an org, then the result comes from an explicit flag store rather than being inferred from tier, route meta or email domain.
  2. Given a flag is enabled for one org, when a member of that org loads the portal, then the flagged UI renders; for every other org it does not.
  3. Given a flag is consumed by the portal, when access info is refreshed, then flags arrive alongside the existing access payload rather than requiring a separate request per flag.
  4. Given a flag is removed, when the code path is cleaned up, then the flag store reports it as stale so dead flags are visible.
  5. Given a flag is evaluated and the store is unreachable, when the fallback runs, then the feature defaults to off.
  6. Given a flag controls a chargeable capability, when it is enabled, then it is recorded in the audit log with a reason.
Implementation notes

Today there is no feature-flag system at all — gating is tier plus route meta plus email domain, spread across app/utils/accessTier.ts (usesRichContentSubscriptionTabs, showRichContentSubscribeOnSearch, hasUnrestrictedRichContentAccess, usesPaidExportSubscriptionModel, listViewValidRouteTabs), AccessManager (hasMetasearchAccess, hasDirectPricingRefreshAccess, licensedSources, isUbioUser) and route meta. Flags would ride the existing AccessManager.refreshAccessInfo() payload, which already has a 30 s TTL cache and request dedup.

Edge cases & risks

Mixing flags with the existing tier logic risks a third source of truth for "can this org do X"; the mapping between flags and entitlements must be explicit or support will not be able to explain why a customer sees a feature. hasDirectPricingRefreshAccess() already carries a code comment about a planned paid add-on, which is precisely the kind of decision a flag would otherwise quietly pre-empt.

ADMN-19

Give sales a revenue and usage dashboard

Proposed
Ubio sales repShould
As a Ubio sales rep, I want to see which accounts are growing, stalling or near a limit, so that I can time expansion conversations instead of discovering usage at renewal.
Acceptance criteria
  1. Given the dashboard loads, when it renders, then it shows active subscriptions by product (smartFeed, metasearch, richContent) and tier (experience, consume, deliver, grow, more) with counts and monthly recurring revenue.
  2. Given an org is approaching an included quota, when the dashboard renders, then it is listed with its current usage and projected overage, using the same maths as deriveRichContentUsage and computeRichContentOverLimit.
  3. Given trials are running, when the dashboard renders, then it shows how far each has consumed ACCESS_TIER_TRIAL_LIMIT and how many days since signup.
  4. Given usage is aggregated, when the dashboard is computed, then it is precomputed on a schedule rather than fanning out live queries across every org's access logs.
  5. Given an org has no telemetry because it uses PATs and MCP only, when it renders, then its API-side usage is still shown from accessLogs, not treated as inactive.
  6. Given revenue figures are shown, when they are read, then they are labelled as derived from Stripe subscription state and not as booked revenue.
Implementation notes

Inputs are organizations.billing (stripeCustomerId, stripeSubscriptionId, stripeStatus, stripePlanName, stripePriceId), the accessLogs aggregation behind GET /stats/, and the plan metadata behind GET /billing/plans?product=. Formatting helpers already exist in utils/billingFormatters.ts.

Edge cases & risks

With all three legacy plans (pro|pro_plus|ultra) currently granting identical access and an open code TODO about whether to gate on plan at all, plan-level revenue analysis may not correspond to any difference in entitlement. Because no usage is metered to Stripe, any revenue number derived here is an internal estimate that can diverge from invoices.

ADMN-20

Surface churn and expiry risk before it happens

Proposed
Ubio sales repShould
As a Ubio sales rep, I want early warning that an account is drifting or about to lose data access, so that I can intervene before a renewal is lost.
Acceptance criteria
  1. Given an org's rich-content subscriptions are within 60 days of expiry, when the risk view renders, then the org is listed with the number of hotels whose 365-day window is ending.
  2. Given an org has scheduled a cancellation, when the risk view renders, then it shows the "Cancels on <date> — access continues until then" state from the subscription record.
  3. Given an org's usage has dropped materially versus its own trailing average, when the risk view renders, then it is flagged as drifting with the specific actions that fell (hotels:view, metasearch:view, export:create:*).
  4. Given an org has repeated payment failures, when invoice.payment_failed events accumulate, then it is escalated as involuntary-churn risk.
  5. Given an org is flagged, when I dismiss the flag with a reason, then it is suppressed for a stated period rather than reappearing daily.
  6. Given a signal is based on fewer than a minimum number of days of data, when it is evaluated, then it is not reported, to avoid flagging new accounts as churning.
Implementation notes

Expiry data already exists client-side as AccessManager.richContentExpiresAt / richContentExpiresInDays(); cancellation state and renewal dates come from GET /billing/subscription and are already rendered by BillingCurrentPlan.vue. Usage trends come from the same accessLogs aggregation as the Usage screen.

Edge cases & risks

Rich-content expiry is per hotel, not per org, so an account can be simultaneously healthy and losing hundreds of subscriptions — the view must be hotel-count aware, not just subscription-aware. Payment-failure signals depend on the Stripe webhook, which currently swallows errors and always returns 200, so missed events would look like healthy accounts.

ADMN-21

Link incidents to runbooks in one internal view

Proposed
Ubio platform engineerCould
As a Ubio platform engineer, I want an internal view that ties a degraded component to its runbook and to the customers it affects, so that an incident response starts with context rather than with a search through chat history.
Acceptance criteria
  1. Given a component is degraded, when the incident view renders, then it names the component (API, search, metasearch proxy, scraping service, exports/GCS, ingest) and links its runbook.
  2. Given metasearch is degraded, when the view renders, then it lists the organisations with active metasearch subscriptions so we know who is affected.
  3. Given exports are failing, when the view renders, then it lists exports currently in processing or error with their org, list and age.
  4. Given scraping is degraded, when the view renders, then it shows the count of scrapingStatuses entries in error by source and the age of the oldest stale hotel against the 30-day threshold.
  5. Given an incident is declared here, when the customer-facing announcement is published, then it is created from the same record rather than written twice.
  6. Given the incident is closed, when it is resolved, then it produces a timestamped entry for the public status page's history.
Implementation notes

Ties together the proposed public status page (MKTG-17) and the in-app announcements (ONBD-19) so there is one incident record with an internal and an external face. Component signals map to real dependencies: METASEARCH_SERVICE_URL, the scraping service's PeriodicTaskRunner tasks, GCS export writes, and MongoDB Atlas Search.

Edge cases & risks

Deriving "who is affected" from subscription state is only approximate — a customer with metasearch access who never uses it is not affected, while a PAT-driven pipeline may be badly affected and invisible in portal telemetry. Runbook links rot fast unless the incident view fails loudly when a runbook target 404s.

Part 10

The data platform

INGS 5 built5 partial12 proposed

Ingestion & the import pipeline

Everything customers see — the 1M+ indexed hotels, the canonical profiles, the availability icons in HotelResultsTable.vue — starts as raw documents in the Static Content DB and is pulled across by the import CLI (packages/import, MONGO_URL_IMPORT, secondaryPreferred). Six data sources feed sourceHotelData5, which is then merged into canonicalData6 by HotelDataUpdateService. Today the pipeline is a hand-run command with hard-coded batch sizes and sources commented in and out of app.ts; this epic covers what exists and what it would take to make ingestion a scheduled, observable, restartable platform service with contracts and freshness guarantees.

INGS-01

Ingest Google Hotels content from `googleDataNew`

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want to import Google hotel content into sourceHotelData5 under the GoogleHotels dsName, so that every property has an anchor record that the canonical identity and merge order depend on.
Acceptance criteria
  1. Given the Static Content DB is reachable, when GoogleHotelsDataSource runs, then documents from googleDataNew are mapped to SourceHotelData with dsName: 'GoogleHotels' and dsHotelId set from the source id.
  2. Given a mapped record, when HotelDataUpdateService.execute runs, then the record is upserted on the unique {dsName, dsHotelId} key rather than duplicated.
  3. Given a Google record resolves a huId, when the canonical record is recomputed, then GoogleHotels appears in _dsNames for every field group it contributed.
  4. Given the source document fails the airtight schema, when validation runs, then the record is rejected and the run continues with the remaining chunk.
Implementation notes

packages/import GoogleHotelsDataSource reads googleDataNew; the dsName is kept as GoogleHotels for compatibility even though the payload is really Google Places data. Downstream it drives HuId generation and top priority in DS_NAMES.

Edge cases & risks

The dsName/content mismatch is a permanent source of confusion for anyone reading _dsNames or refs; because this source is also the identity anchor, a bad or partial Google import silently shrinks the addressable catalogue rather than failing loudly.

INGS-02

Ingest Google reviews from `googleReviews`

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want Google reviews imported into the reviews collection under the same GoogleHotels dsName, so that GET /hotels/{huId}/reviews and the reviews export have content to serve.
Acceptance criteria
  1. Given review documents in googleReviews, when GoogleReviewsDataSource runs, then each review is mapped to HotelReview with source, reviewer.name, overallRating and original.text.
  2. Given a review carries a translation, when it is mapped, then translation is populated and original.languageCode is preserved.
  3. Given reviews are written, when canonicalReviewData1 is updated, then _dsNames.reviews records GoogleHotels as the contributing source.
  4. Given a review has no resolvable huId, when the import runs, then it is not written to the canonical review document and the run does not abort.
Implementation notes

GoogleReviewsDataSource extends AbstractReviewsDataSource; the export path (HotelReviewExport) strips reviewer so PII never leaves the platform via POST /lists/{listId}/exports with type: reviews.

Edge cases & risks

Review volume dwarfs hotel volume, so an unbounded review import is the most likely cause of a long-running or memory-hungry run; reviews orphaned by a failed huId resolution are silently dropped rather than queued for retry.

INGS-03

Ingest Google images with validity and URL-type filtering

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want only valid, correctly typed Google images imported, so that hotel galleries in HotelImages.vue do not fill with dead or irrelevant URLs.
Acceptance criteria
  1. Given a document in googleImages, when GoogleImagesDataSource runs, then it is imported only if metadata.isValid is true.
  2. Given a valid image, when its urlType is checked, then only hotel, ota and otherNonGoogle types are imported.
  3. Given an image is imported, when the URL is written, then it is rewritten to https://images.automation.cloud/img?hash=<ciphertext>.<iv> by encodeImageUrls.
  4. Given an image arrives with width, height and caption, when it flows through the canonical recompute path, then those fields are currently lost even though ImageUpdateService preserves them.
Implementation notes

GoogleImagesDataSource extends AbstractImagesDataSource and writes SourceImageData (which carries pixels, aspectRatio, compressionRatio, estimatedQuality). The named gap is the known bug where the encodeImageUrls path drops width/height/caption.

Edge cases & risks

Losing dimensions defeats the img/resize sizing heuristics and the portal's dedupe (utils/imageDedupe.ts); the outstanding "deduplicate images" TODO in ImageUpdateService means the same photo can appear several times in one gallery.

INGS-04

Ingest Booking.com properties through the source mapper

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want rawBookingHotels mapped into the canonical shape, so that Booking contributes rooms, facilities, policies and category scores that Google does not provide.
Acceptance criteria
  1. Given documents in rawBookingHotels, when BookingDataSource runs, then they are mapped to SourceHotelData with dsName: 'Booking'.
  2. Given a Booking record with room data, when it is mapped, then HotelRoom entries carry surfaceM2, maxOccupancy, bedConfiguration[], roomFacilities[] and roomViewOptions[].
  3. Given Booking ratings on a 0–10 scale, when HotelRating is populated, then overallRating.min/max record the source scale rather than silently rescaling.
  4. Given a raw field is absent or an unexpected shape, when the mapper runs, then the field is omitted and the record still validates rather than the whole document being rejected.
Implementation notes

The Booking mapper is the largest in packages/import at 684 lines and is the main supplier of rooms, features and rating.categoryScores in the merge.

Edge cases & risks

A 684-line hand-written mapper with no golden-file tests is the single most fragile piece of ingestion; a Booking payload change can degrade rooms and amenities for a large share of the catalogue without any validation error firing.

INGS-05

Ingest TripAdvisor and Expedia source records

Partial
Ubio data ops engineerShould
As a Ubio data ops engineer, I want TripAdvisor and Expedia records imported, so that customers can resolve their own tripAdvisorHotelId and expediaHotelId references against canonical hotels.
Acceptance criteria
  1. Given documents in tripadvisorDataNew, when TripAdvisorDataSource runs, then records are written with dsName: 'TripAdvisor' and populate refs.tripAdvisorHotelId.
  2. Given documents in expediaHotels, when ExpediaDataSource runs, then records are written with dsName: 'Expedia'.
  3. Given a customer uploads a CSV with hotelIdType: expediaHotelId, when matchRecordsToHotelsByIds runs, then rows resolve against the imported Expedia refs.
  4. Given Expedia supplies only a minimal field set, when a canonical record is built solely from Expedia, then meta availability flags reflect the missing groups rather than claiming data exists.
Implementation notes

ExpediaDataSource maps a deliberately minimal field set, so Expedia mostly contributes reference ids and identity rather than content; both sit last in DS_NAMES priority.

Edge cases & risks

Because Expedia is thin and lowest priority, an Expedia-only property produces a near-empty profile that still counts towards indexed-hotel totals; that inflates coverage claims such as HOTELS_INDEXED = 1M+ relative to usable rich content.

INGS-06

Stream source collections in sorted, projected chunks

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want each source read in _id-sorted chunks with per-schema projections, so that a multi-million-document import runs in constant memory against a secondary.
Acceptance criteria
  1. Given a source collection, when the import runs, then documents are read in chunks with a default size of 100,000 sorted by _id.
  2. Given a schema declares the fields it needs, when the chunk query is built, then a per-schema projection restricts the fields fetched.
  3. Given the import connects to the Static Content DB, when the connection is made, then it uses MONGO_URL_IMPORT with secondaryPreferred read preference.
  4. Given a long-running chunk loop, when the operator watches the terminal, then a progress bar reports position through the source.
  5. Given the cursor is interrupted mid-collection, when the process exits, then the run has no record of where it stopped and must be re-driven manually.
Implementation notes

Chunked streaming lives in the AbstractDataSource contract shared by all six sources; the same constant-memory pattern is used by ExportService's Readable.from(dbStream) → transform → toJsonArray → gzip → GCS.

Edge cases & risks

secondaryPreferred can serve a lagging replica, so a run may import a stale snapshot with no signal that it did; sorting by _id provides stable paging but says nothing about recency, so the newest records are always imported last.

INGS-07

Validate every source record against an airtight schema

Built
Customer data engineerMust
As a Customer data engineer, I want every ingested record validated before it is stored, so that the fields I read from GET /hotels/{huId} and the full export always match the documented schema.
Acceptance criteria
  1. Given a mapped source record, when HotelDataUpdateService runs, then the record is validated against its airtight schema before any write.
  2. Given a record violates an enum such as PROPERTY_CODES or a HotelFeature.category value, when validation runs, then the write is rejected.
  3. Given info.name exceeds 1000 characters or starRating falls outside 0–5, when validation runs, then the record is rejected rather than truncated.
  4. Given a record is rejected, when the run continues, then the failure is reported in the run output but the record itself is not retained anywhere.
Implementation notes

Validation is the first step of the merge (validate → findHuId → upsert into sourceHotelData5 → recompute canonical) and is covered by the HotelDataUpdateService unit tests.

Edge cases & risks

Rejected records vanish, so a mapper regression looks identical to an upstream data gap; there is no counter distinguishing "source had 0 rows" from "10% of rows failed validation".

INGS-08

Buffer image writes to survive high-volume image imports

Partial
Ubio platform engineerShould
As a Ubio platform engineer, I want image writes buffered and flushed in batches, so that importing tens of millions of images does not saturate the HU database with single-document writes.
Acceptance criteria
  1. Given images are being imported, when 100 records accumulate, then ImageUpdateService flushes them as one batch.
  2. Given fewer than 100 records are pending, when 5 seconds elapse, then the buffer flushes anyway.
  3. Given the process shuts down, when a partial buffer is pending, then it is flushed before exit rather than discarded.
  4. Given the same image URL arrives twice, when it is buffered, then it is currently written twice — the deduplication TODO is unresolved.
Implementation notes

ImageUpdateService implements the 100-record / 5-second buffer and preserves width/height/caption, unlike the canonical encodeImageUrls path (see INGS-03).

Edge cases & risks

Duplicate images inflate meta.images.available counts and gallery sizes; a crash between flushes loses up to 5 seconds of writes with no replay, and the buffered path masks slow-write symptoms until the database is already saturated.

INGS-09

Require authentication on the `POST /sources` ingest endpoint

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the source-update endpoint to require a scoped credential, so that nobody outside Ubio can inject or overwrite hotel source data in production.
Acceptance criteria
  1. Given a request to POST /sources with no Authorization header, when it is handled today, then it is accepted — this is the known bug.
  2. Given the fix, when an unauthenticated request arrives, then the API responds 401 and writes nothing.
  3. Given a token without an ingest scope, when it calls POST /sources, then the API responds 403.
  4. Given a valid internal credential, when it calls POST /sources, then the payload is validated and merged exactly as the import CLI would.
Implementation notes

POST /sources currently has no auth check, alongside /scrapers/* and /img/resize. The fix should add a new scope to the existing permission set (orgs:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage, exports:manage) rather than relying on isUbee() email matching.

Edge cases & risks

The endpoint reaches HotelDataUpdateService, so an anonymous caller can poison canonical records for every customer; because the merge respects DS_NAMES priority, an injected GoogleHotels payload would outrank all genuine sources.

INGS-10

Configure sources and batch sizes without editing code

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want per-source run configuration and CLI flags, so that I can run one source over a chosen range without editing and redeploying app.ts.
Acceptance criteria
  1. Given the CLI, when I pass --source booking --batch-size 50000 --skip 0, then only BookingDataSource runs with those parameters.
  2. Given a source config file, when it lists enabled sources, then the runner reads it instead of requiring lines in app.ts to be commented in and out.
  3. Given no flags are supplied, when the CLI runs, then it uses the documented per-source defaults and prints the effective configuration before starting.
  4. Given an unknown source name or a negative batch size, when the CLI starts, then it exits non-zero with a validation error and imports nothing.
Implementation notes

Replaces the hard-coded batch sizes and skips in packages/import/app.ts with mesh-config-backed per-source settings, keeping the AbstractDataSource contract unchanged so each source class is untouched.

Edge cases & risks

Configuration drift between the file and the deployed image can silently disable a source; the effective-config printout and a config hash in the run record are what keep "which sources actually ran" answerable.

INGS-11

Run ingestion as a scheduled orchestrated pipeline

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want the six sources to run on a schedule with declared ordering and dependencies, so that the catalogue refreshes without anyone remembering to run a CLI.
Acceptance criteria
  1. Given a schedule definition, when the window opens, then sources run in dependency order with Google first so that huId anchors exist before dependent sources resolve.
  2. Given a stage fails, when the orchestrator evaluates the run, then downstream stages that depend on it are skipped and marked blocked rather than run against incomplete data.
  3. Given a run is already in progress, when the next schedule fires, then the new run is skipped or queued and never runs concurrently against the same source.
  4. Given a run finishes, when it completes, then a run record captures start, end, per-source counts and final status.
Implementation notes

Would build on the existing PeriodicTaskRunner/Sleepbox pattern from packages/scrapers rather than introducing a new scheduler, deploying the import package as a long-running service alongside hotel-universe-scraping-service.

Edge cases & risks

Ordering matters more than it looks: running Booking before Google leaves records with huId: null awaiting the link CLI, which inflates the unresolved backlog and the Google Places API bill.

INGS-12

Make ingest runs idempotent and restartable from a checkpoint

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want each run to checkpoint its position and resume, so that a crash eight hours into a source does not mean restarting from the first document.
Acceptance criteria
  1. Given a chunked run, when a chunk completes, then the last processed _id is persisted as a checkpoint for that source and run.
  2. Given a run is restarted, when a checkpoint exists, then processing resumes from the next _id rather than from the beginning.
  3. Given the same chunk is processed twice, when records are written, then upserts on {dsName, dsHotelId} leave the database in the same state as a single pass.
  4. Given a checkpoint refers to a source whose configuration has changed, when the run restarts, then it refuses to resume and requires an explicit full run.
Implementation notes

Uses the existing _id-sorted chunk iteration as the natural checkpoint boundary; upserts already make source writes idempotent, so the new work is checkpoint persistence and resume logic in AbstractDataSource.

Edge cases & risks

Resuming across a changed projection or mapper silently produces a half-old, half-new catalogue; new documents inserted with lower _id values than the checkpoint are skipped entirely until the next full pass.

INGS-13

Store records that fail validation in a dead-letter collection

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want rejected records kept with their validation error, so that I can diagnose a mapper regression instead of guessing why counts dropped.
Acceptance criteria
  1. Given a record fails its airtight schema, when validation rejects it, then the raw document, dsName, dsHotelId, error path and run id are written to a dead-letter collection.
  2. Given dead-letter entries exist, when I query by run id, then I can group failures by error path to see the dominant cause.
  3. Given a mapper fix is deployed, when I replay a dead-letter batch, then successfully validated records are merged normally and their dead-letter entries are marked resolved.
  4. Given the dead-letter collection grows unboundedly, when the retention window passes, then entries older than the configured TTL are removed.
Implementation notes

Mirrors the accessLogs approach of a purpose-built collection with a TTL; hooks into the validate step of HotelDataUpdateService so it captures failures from both the import CLI and the scrapers, which share that service.

Edge cases & risks

Raw upstream documents may contain licensed third-party content and reviewer PII, so the dead-letter store needs the same retention discipline as HotelReviewExport's reviewer stripping rather than becoming a shadow copy of source data.

INGS-14

Emit per-source ingest metrics and a coverage dashboard

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want counts of read, validated, rejected, inserted and updated records per source per run, so that I can tell a genuine upstream gap from a broken mapper within minutes.
Acceptance criteria
  1. Given a run completes, when metrics are emitted, then each source reports records read, validated, rejected, inserted, updated and duration.
  2. Given a run's rejected count exceeds a configured percentage of records read, when the run finishes, then it is flagged as degraded.
  3. Given several runs of history, when I open the dashboard, then per-source volumes are charted over time so a step change is visible.
  4. Given a source produced zero records, when the run finishes, then that is reported as an error condition rather than a successful empty run.
Implementation notes

Would replace the terminal progress bars as the primary signal and feed the same dashboard surface proposed for scraper health (SCRP-14), keyed by dsName so it lines up with DS_NAMES and _dsNames provenance.

Edge cases & risks

Insert/update ratios are the earliest warning of an identity regression — a spike in inserts usually means findHuId stopped resolving rather than that the world gained hotels.

INGS-15

Detect schema drift when an upstream source changes shape

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want an alert when a source collection's document shape changes, so that a silently dropped field is caught before it reaches customers' exports.
Acceptance criteria
  1. Given a sample of documents from a source collection, when the run starts, then observed field paths and types are compared against the stored profile for that source.
  2. Given a previously present field is now missing in more than a configured share of the sample, when drift is evaluated, then the run raises a drift alert naming the field.
  3. Given a new unmapped field appears, when drift is evaluated, then it is reported as an opportunity rather than an error.
  4. Given drift is detected on a critical field such as the source's primary id, when the run evaluates it, then the run aborts before writing anything.
Implementation notes

Profiles would be built from the same per-schema projections the chunked reader already declares, so drift detection covers exactly the fields the mappers care about — including the Booking mapper's 684 lines of field access.

Edge cases & risks

Sampling the first chunk by _id order biases towards the oldest documents, which are precisely the ones least likely to show a new upstream shape; the sample must be spread across the _id range.

INGS-16

Provide a dry-run mode that diffs against production before writing

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want to run a source in dry-run and see what would change, so that I can approve a mapper change before it rewrites a million canonical records.
Acceptance criteria
  1. Given --dry-run, when a source runs, then no writes are made to sourceHotelData5 or canonicalData6.
  2. Given a dry run completes, when I read the report, then it lists per-field-group counts of records that would be created, changed or left untouched.
  3. Given a sample size is configured, when the report is produced, then it includes before/after examples for the most frequently changed fields.
  4. Given a dry run would change more than a configured share of the catalogue, when the report is produced, then it is marked as requiring explicit sign-off before a live run.
Implementation notes

Reuses combineData and computeMeta to compute the prospective canonical record in memory and diff it against the stored one, without the final upsert — the same field groups (refs, info, location, rating, features, images, rooms) drive the report.

Edge cases & risks

A dry run against a lagging secondary can under-report changes; diffing whole records for a million hotels is itself expensive, so the report needs sampling plus aggregate counts rather than a full per-record diff.

INGS-17

Add new data sources as plugins

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want to add a data source by dropping in a class that satisfies the abstract contracts, so that onboarding a new supplier does not mean touching the runner, the config and DS_NAMES by hand.
Acceptance criteria
  1. Given a new class implementing AbstractDataSource, when it is registered, then the runner discovers it without changes to the orchestration code.
  2. Given a plugin declares its dsName, source collection and projection, when the run starts, then chunking, validation and merge behaviour are inherited unchanged.
  3. Given a plugin declares its position in the merge priority, when combineData runs, then DS_NAMES ordering is derived from registered plugins rather than a hard-coded array.
  4. Given a plugin fails to declare a required contract member, when the app boots, then registration fails loudly at startup rather than mid-run.
Implementation notes

Builds on the existing AbstractDataSource / AbstractReviewsDataSource / AbstractImagesDataSource split and mesh-ioc registration, which already gives most of the plumbing; the remaining work is making DS_NAMES and the runner data-driven.

Edge cases & risks

Merge priority is a business decision, not a plugin detail — letting a new source declare its own rank risks a low-quality supplier outranking Google on info for a whole segment of the catalogue.

INGS-18

Define and monitor per-source freshness SLAs

Proposed
OTA product managerMust
As an OTA product manager, I want a published freshness target for each source, so that I can trust the "365 days of automatic updates" promise I am buying against.
Acceptance criteria
  1. Given each source, when its SLA is configured, then a maximum acceptable age is recorded (for example content refreshed within 30 days, matching staleTimeThresholdMs).
  2. Given a hotel's crawledAt or last source update exceeds the SLA, when freshness is evaluated, then the hotel is counted as out of SLA for that source.
  3. Given a customer list, when I view its coverage, then I can see the share of hotels within SLA per source.
  4. Given a source breaches its SLA across more than a configured share of the catalogue, when the check runs, then an alert fires and the status is reflected rather than reported as "all systems green".
Implementation notes

SourceHotelData.crawledAt and canonicalData6.updatedAt already carry the timestamps; the missing pieces are the SLA definitions, the rollup and a customer-visible surface next to the existing ListStats counts.

Edge cases & risks

Freshness varies wildly by source and geography, so a single global SLA would be either meaningless or permanently breached; publishing freshness externally also makes any scraper outage immediately visible to customers.

INGS-19

Backfill a country or id range on demand

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want to re-import a specific country or _id range, so that I can repair a known bad segment without re-running a full source.
Acceptance criteria
  1. Given --country ES, when the backfill runs, then only source documents resolving to location.countryCode ES are re-imported.
  2. Given --id-range <from>..<to>, when the backfill runs, then only source documents in that _id range are processed.
  3. Given a backfill completes, when its run record is written, then it is distinguishable from a scheduled full run.
  4. Given a filter matches no documents, when the backfill starts, then it exits with a clear message rather than reporting a successful empty run.
Implementation notes

Extends the CLI flags from INGS-10 and reuses the chunked _id reader; a country filter needs either an upstream country field or a two-pass approach driven by resolved canonicalData6.location.countryCode.

Edge cases & risks

Country is a canonical attribute, not necessarily a source attribute, so a country backfill can only reach hotels already resolved — the very records missing from the catalogue are the ones the filter cannot find.

INGS-20

Agree a data contract with each upstream source

Proposed
Ubio data ops engineerShould
As a Ubio data ops engineer, I want a written contract per source covering fields, refresh cadence, volumes and change notice, so that upstream changes arrive as notifications rather than as production incidents.
Acceptance criteria
  1. Given a source, when its contract is recorded, then it lists required fields, expected document volume range, refresh cadence and a notice period for breaking changes.
  2. Given a run completes, when volumes fall outside the contracted range, then the run is flagged as a contract breach.
  3. Given a contracted required field is missing, when validation runs, then the failure references the contract clause rather than only the schema path.
  4. Given a contract has no owner recorded, when it is reviewed, then it is flagged as unmaintained.
Implementation notes

The contract would formalise what the per-schema projections and airtight schemas already assume implicitly, and gives INGS-15 drift detection and INGS-14 volume alerts their thresholds.

Edge cases & risks

Scraped sources have no counterparty to contract with, so for Google Travel, Booking and TripAdvisor the "contract" is really an internal expectation document — which makes the parser-regression tests in SCRP-16 the actual enforcement mechanism.

INGS-21

Report ingest progress somewhere other than a terminal

Partial
Ubio support agentCould
As a Ubio support agent, I want to see whether an import is running and how far it has got without shell access, so that I can answer "when will this hotel appear?" without escalating to engineering.
Acceptance criteria
  1. Given an import is running today, when I want its status, then the only signal is a progress bar in the operator's terminal.
  2. Given the improvement, when a run is in progress, then its source, position, rate and estimated completion are readable from a status surface.
  3. Given a run has finished, when I look it up, then its outcome and per-source counts remain available.
  4. Given no run is active, when I check, then the surface states when the last run for each source completed rather than showing nothing.
Implementation notes

The progress package output would be replaced or supplemented by run records written to the HU DB, readable by the same internal tooling that serves GET /stats/ and the ubio-only screens gated by isUbee().

Edge cases & risks

Without persisted run records, a run that dies with its terminal is indistinguishable from one that never started; support currently has no way to tell a customer whether missing data is a gap or a queue.

INGS-22

Publish per-source coverage counts to customers

Proposed
Data analystCould
As a Data analyst, I want to see how many hotels in my list carry data from each source and each field group, so that I can judge whether the catalogue supports the analysis I am planning.
Acceptance criteria
  1. Given a list, when I request coverage, then I see counts of hotels with location, rating, features, reviews, images and rooms available.
  2. Given coverage is returned, when it is broken down, then it also shows which sources contributed, derived from _dsNames.
  3. Given a hotel has an availability flag set but no underlying data, when coverage is computed, then it is counted as unavailable rather than trusting the sticky flag.
  4. Given my organisation is on the trial tier, when I request coverage, then aggregate counts are returned without exposing per-hotel detail beyond the trial limit.
Implementation notes

Extends GET /lists/{listId}/stats (today {total, hotels}) with the same six availability dimensions already rendered as icons in HotelResultsTable.vue and ListEntries.vue, sourced from HotelMeta and _dsNames.

Edge cases & risks

computeMeta makes images and reviews sticky, so raw meta flags overstate coverage; publishing them without the recompute in criterion 3 would put an inaccurate number in front of paying customers.

SCRP 6 built6 partial10 proposed

Scraping, freshness & platform operations

Content that no partner supplies as a feed is extracted by packages/scrapers, a long-running service that schedules four tasks over spoofed mobile, protobuf and persisted-GraphQL endpoints behind a shared proxy, then hands results to the same HotelDataUpdateService the import CLI uses. Around it sit the image encryption and resize path, the GCS export bucket and a tag-driven CI/CD pipeline into eu.gcr.io. This epic covers what runs today and the operational maturity — retries, block detection, parser regression tests, cost tracking, secret management and SLOs — that a system making unauthenticated requests to third parties at 50-way concurrency needs before it can honestly claim "all systems green".

SCRP-01

Run scraping tasks on a periodic schedule

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want the scraping service to run its tasks on a loop with a clean shutdown, so that content refreshes continuously without a person driving it.
Acceptance criteria
  1. Given the service starts, when PeriodicTaskRunner.schedule(task, interval, name) registers the four tasks, then each runs every SCRAPING_INTERVAL seconds (default 60).
  2. Given RUN_TASKS_ON_START is false (the default), when the service boots, then it waits one interval before the first run.
  3. Given the service receives a shutdown signal, when a task is mid-run, then the interruptible Sleepbox wakes and the process drains before exiting.
  4. Given a task throws, when the runner catches it, then the loop continues and the next interval still fires.
Implementation notes

PeriodicTaskRunner plus Sleepbox in packages/scrapers; the four registered tasks are GoogleHotels, BookingHotels, BookingRooms and TripAdvisorHotels. The image is deployed as hotel-universe-scraping-service.

Edge cases & risks

A 60-second interval combined with a 100-hotel batch and 50-way concurrency means overlapping runs if a batch takes longer than the interval; nothing currently prevents a slow source from stacking work.

SCRP-02

Select stale hotels for re-scraping

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want each cycle to pick the hotels most in need of a refresh, so that scraping capacity goes to stale records rather than re-scraping the same properties.
Acceptance criteria
  1. Given a scraping cycle, when getStaleHotels runs, then it selects up to batchLimit (100) hotels and processes them at concurrency (50).
  2. Given a hotel with no status for the source and type, when selection runs, then it is eligible.
  3. Given a hotel whose last status was success older than staleTimeThresholdMs (30 days), when selection runs, then it is eligible.
  4. Given the required reference id is absent (for example no bookingHotelId), when selection runs, then the hotel is not selected for that source.
  5. Given a hotel whose last status was error, when selection runs, then it is never picked up — the TODO noting that error re-scraping should be a separate method is unresolved.
Implementation notes

AbstractScrapingService holds batchLimit, concurrency and staleTimeThresholdMs; the HMRC R&D document cites 50 concurrent scrapes in batches of 100 as a design target.

Edge cases & risks

Criterion 5 is a permanent data hole: any hotel that errors once is excluded from that source forever, so error counts do not shrink and coverage quietly degrades for exactly the hardest properties.

SCRP-03

Track scraping state per source and per data type

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want the state of every scrape recorded per source and per data type, so that work selection and diagnosis both have a single source of truth.
Acceptance criteria
  1. Given a scrape starts, when the lifecycle begins, then the record's status for that source and type is set to scraping.
  2. Given the fetch, checkData and transformData steps succeed and HotelDataUpdateService.execute completes, then the status becomes success with updatedAt refreshed.
  3. Given any step throws, when the lifecycle ends, then the status becomes error with the error message stored.
  4. Given a record is in scraping state, when the next cycle selects work, then it is not selected again concurrently.
  5. Given a process dies mid-scrape, when the record is inspected, then it remains stuck in scraping with nothing to release it.
Implementation notes

scrapingStatuses stores {refs, scraping: Record<'GooglePlaces'|'Booking'|'TripAdvisor', {hotel?, rooms?, reviews?}|null>} with {status, error, updatedAt} per entry.

Edge cases & risks

Criterion 5 is the flip side of criterion 4 — crashed runs leave permanently locked records that neither succeed, error nor become eligible again; there is no lease expiry.

SCRP-04

Extract Google Travel hotel content

Partial
Ubio data ops engineerMust
As a Ubio data ops engineer, I want Google Travel content extracted for hotels that have a Google reference, so that the highest-priority source in the merge stays current.
Acceptance criteria
  1. Given a googleHotelId, when the remote builds a request, then a protobuf-encoded qs URL is constructed against GOOGLE_TRAVEL_BASE_URL.
  2. Given the request is issued, when headers are set, then a spoofed user agent and cookies generated by CookieGenerationService are attached.
  3. Given a response, when it is parsed, then the HTML and embedded-JSON parsers extract fields that GoogleTravelMappingService maps to the canonical shape.
  4. Given the parser finds no recognisable payload, when checkData runs, then the scrape is marked error rather than writing an empty record.
  5. Given the auxiliary NodeScript endpoint is used, when it is called, then it retries up to 5 times with a 30-second timeout and requires no authentication.
Implementation notes

Covered in part by the GoogleTravelHtmlParser unit test; the same scraper classes are registered in the API app to serve GET /scrapers/google.

Edge cases & risks

Protobuf qs construction, cookie generation and HTML parsing are all undocumented contracts that Google can change without notice; the unauthenticated NodeScript endpoint is an open dependency anyone who discovers it can drive.

SCRP-05

Extract Booking.com hotel, room and review data

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want Booking hotel pages, facilities, policies, review scores and room lists retrieved, so that the canonical record has the rooms and amenities Google does not supply.
Acceptance criteria
  1. Given a bookingHotelId, when the remote runs, then it calls the mobile-app JSON endpoints bookings.getHotels, mobile.hotelPage, getHotelReviewScores, getHotelDescriptionTranslations, getPolicies and mobile.roomList.
  2. Given facilities are needed, when the remote runs, then the /dml/graphql facilities query is issued.
  3. Given any request is made, when headers are built, then the spoofed Booking.App/47.1 iOS/17.5 identity with fixed device id, affiliate id, latitude and longitude is used.
  4. Given room data is requested, when the probe is built, then a "best date" 1-night USD stay is used to elicit a room list.
  5. Given the endpoint returns an error payload or an unexpected shape, when checkData runs, then the scrape is marked error.
Implementation notes

Configured via BOOKING_* env vars; BookingScraper has unit-test coverage, and the same classes back GET /scrapers/booking and GET /scrapers/booking/rooms (displayType full|short).

Edge cases & risks

A frozen app version string ages into a fingerprint: Booking.App/47.1 iOS/17.5 with a fixed device id makes every request from the platform look like one device, and the fixed lat/lon can skew which properties and prices are returned.

SCRP-06

Extract TripAdvisor content via persisted GraphQL queries

Built
Ubio data ops engineerShould
As a Ubio data ops engineer, I want TripAdvisor detail and amenity data retrieved, so that TripAdvisor contributes ratings, review mentions and amenities to the merge.
Acceptance criteria
  1. Given a tripAdvisorHotelId, when the remote runs, then QueryHotelAppDetail and PoiAmenities are issued as persisted queries identified by sha256 hash.
  2. Given a request, when it is built, then the configured API key, UUID and proxy are applied.
  3. Given no locale override, when defaults are applied, then USD, en_UK and MILES are used.
  4. Given TripAdvisor rejects the persisted query hash, when the response is checked, then the scrape errors with a message identifying the hash rather than silently returning nothing.
Implementation notes

Configured via TRIPADVISOR_* env vars in packages/scrapers; results feed rating.reviewMentionRatings[] and features through the TripAdvisor feature and category-score maps.

Edge cases & risks

Persisted query hashes are rotated by TripAdvisor without notice, so this integration fails wholesale rather than gradually — which makes it the clearest case for the parser-change detection in SCRP-16.

SCRP-07

Route all scraping traffic through the shared proxy

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want every outbound scraping request to use the configured proxy, so that our own infrastructure addresses are not the ones being rate-limited or blocked.
Acceptance criteria
  1. Given PROXY_HOST, PROXY_USERNAME and PROXY_PASSWORD are set, when any remote makes a request, then it is dispatched through the shared undici ProxyAgent.
  2. Given the proxy is unreachable, when a request is attempted, then the scrape errors rather than silently falling back to a direct connection.
  3. Given proxy configuration is absent, when the service boots in production, then this is surfaced as a misconfiguration.
  4. Given the agent is constructed, when TLS options are applied, then rejectUnauthorized: false is set, disabling certificate verification for all proxied traffic.
Implementation notes

One shared ProxyAgent is used by the Google, Booking and TripAdvisor remotes in packages/scrapers.

Edge cases & risks

rejectUnauthorized: false means a compromised or hostile proxy can transparently intercept and alter every scraped payload — which then flows through HotelDataUpdateService into canonical records that customers pay for.

SCRP-08

Normalise raw addresses into structured location fields

Partial
Customer data engineerShould
As a Customer data engineer, I want free-text addresses split into structured fields, so that country, city and postcode filters work on scraped hotels as well as feed-sourced ones.
Acceptance criteria
  1. Given a rawAddress, when AddressSplittingService runs, then it posts {rawAddress} to NS_ADDRESS_SPLITTING_ENDPOINT.
  2. Given a successful response, when it is mapped, then HotelLocation fields streetAddress, city, postalCode, countryCode, country, state and province are populated.
  3. Given the service is unavailable or times out, when the scrape continues, then rawAddress is retained and the structured fields are left empty.
  4. Given a returned countryCode is not a valid ISO alpha-2 code, when validation runs, then the field is rejected rather than stored.
Implementation notes

The service is an external NodeScript endpoint, so address quality for scraped hotels depends on a component outside this codebase; IsoCodes.ts and Countries.ts supply the validation vocabulary.

Edge cases & risks

Structured location drives the country filter, the postcode signal in AddressMatcher (weight 0.3) and geo search, so a degraded splitting service quietly hurts both search and entity resolution at once.

SCRP-09

Serve scraped images through the encryption and resize path

Partial
Ubio platform engineerShould
As a Ubio platform engineer, I want image URLs proxied through our own resize service, so that the portal renders galleries quickly without exposing upstream image hosts directly.
Acceptance criteria
  1. Given an image URL is ingested, when encodeImageUrls runs, then it is rewritten to https://images.automation.cloud/img?hash=<AES-256-CBC ciphertext>.<iv> with a random IV and url-safe base64.
  2. Given a request to GET /img/resize?hash&w&h, when it is served, then sharp performs a cover resize with cubic interpolation and returns JPEG at quality 75.
  3. Given the request carries no credentials, when it is served, then it succeeds because the endpoint has no auth.
  4. Given a response is returned, when headers are inspected, then no cache-control header is present because it is commented out.
  5. Given a malformed or undecryptable hash, when the request is served, then an error is returned rather than an upstream fetch being attempted.
Implementation notes

The AES-256-CBC key is hard-coded in the repository; the img app is the single endpoint in that package.

Edge cases & risks

A hard-coded key plus an unauthenticated endpoint makes the resizer an open image proxy for anyone who can mint a hash, and the missing cache-control means every gallery view re-resizes from origin — the cost and the abuse vector compound.

SCRP-10

Store and expire export artefacts in Cloud Storage

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want exports written to a lifecycle-managed bucket with short-lived download links, so that large customer exports do not accumulate cost or remain downloadable indefinitely.
Acceptance criteria
  1. Given an export completes, when it is uploaded, then GoogleCloudStorageService.uploadStream writes it as application/gzip, non-resumable, with no-cache, under the key {listId}/{exportId}/{type}-{timestamp}.json.gz.
  2. Given a download is requested, when GET /lists/{listId}/exports/{exportId}/download succeeds, then a v4 signed URL valid for 15 minutes is returned with filename, expiresAt and fileSize.
  3. Given the export record has passed expiresAt (30 days after creation), when a download is requested, then the API responds 410.
  4. Given the bucket lifecycle runs, when an object exceeds 30 days, then it is deleted, with 7-day soft delete as the recovery window.
  5. Given the upload fails, when the export status is set, then it is error and the customer sees the generic failure message rather than a broken download.
Implementation notes

GCS_PROJECT_ID, GCS_BUCKET_NAME, GCS_KEY_FILE, GCS_SERVICE_ACCOUNT_KEY; documented bucket hotel-universe-exports-dev in europe-west1, with npm run verify:gcs as the setup check.

Edge cases & risks

The -dev bucket name in the documentation suggests environment separation may not be clean; a 15-minute signed URL is fine for a browser click but awkward for the automated pipelines that PAT-driven integrations actually use.

SCRP-11

Build and deploy tagged service images

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want a tag push to build and deploy both services reproducibly, so that releases are traceable to a specific commit and image.
Acceptance criteria
  1. Given a tag is pushed, when the GitHub Actions workflow runs, then hotel-universe-api and hotel-universe-scraping-service images are built and pushed to eu.gcr.io/automation-cloud-registry.
  2. Given the images are pushed, when the workflow continues, then production deploys are dispatched to ubio/infrastructure.
  3. Given an image is built, when it runs, then it uses Node 22 alpine as a non-root user with uid 1001 and listens on port 8080.
  4. Given the build or image push fails, when the workflow evaluates, then no deploy is dispatched.
Implementation notes

GET /status returns {version, service, timestamp}, which the portal's ProfileMenu.vue renders as API: v{status.version} next to Portal: v{__PORTAL_VERSION__} — the current way a release is confirmed in production.

Edge cases & risks

The two images ship on the same tag but share HotelDataUpdateService and the schemas, so a partial rollout can have a scraper writing a shape the deployed API does not expect; there is no described rollback path beyond re-tagging.

SCRP-12

Close the unauthenticated scraper surfaces

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the on-demand scraper endpoints to require a credential, so that third parties cannot use our proxy pool and API keys as a free scraping service.
Acceptance criteria
  1. Given GET /scrapers/google, GET /scrapers/booking or GET /scrapers/booking/rooms is called with no token today, when the request is handled, then it succeeds — the known bug.
  2. Given the fix, when an unauthenticated request arrives, then the API responds 401 and issues no outbound request.
  3. Given an authenticated caller without the required scope, when they call a scraper endpoint, then the API responds 403.
  4. Given an authenticated internal caller, when they call a scraper endpoint, then the request is rate-limited per principal rather than unbounded.
Implementation notes

These endpoints register the same scraper classes as packages/scrapers inside the API app; rate limiting already exists via rate-limiter-flexible (RATE_LIMIT_REQUESTS 100 / RATE_LIMIT_DURATION_SECONDS 60) but is applied only to GET /metasearch.

Edge cases & risks

Each anonymous call spends proxy bandwidth, TripAdvisor API-key quota and Google request budget, and drives traffic that is attributed to our proxy identity — so abuse here directly raises the block risk for the scheduled pipeline.

SCRP-13

Retry failed scrapes with backoff in a dedicated lane

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want errored records retried on their own schedule with exponential backoff, so that a transient failure does not permanently remove a hotel from a source.
Acceptance criteria
  1. Given a scrape ends in error, when the retry lane runs, then the record becomes eligible after a backoff interval that grows with consecutive failures.
  2. Given a retry succeeds, when the status is written, then the failure counter resets and the record returns to the normal staleness cycle.
  3. Given a record exceeds a maximum retry count, when it is evaluated, then it is parked with a terminal reason and reported rather than retried forever.
  4. Given a record is stuck in scraping beyond a lease timeout, when the lane runs, then it is released and treated as a failure.
  5. Given the retry lane is running, when the main batch selects work, then the two lanes do not select the same record concurrently.
Implementation notes

Resolves the explicit TODO in AbstractScrapingService that error re-scraping should be a separate method, and closes the stuck-scraping hole in scrapingStatuses noted in SCRP-03.

Edge cases & risks

Retrying a record that fails because the hotel no longer exists upstream burns budget indefinitely; the terminal-reason classification in criterion 3 is what separates a retry lane from a loop.

SCRP-14

Define scraping SLOs with health dashboards and alerting

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want per-source success rates, freshness and error budgets visible and alerted on, so that a broken source is detected by us rather than reported by a customer.
Acceptance criteria
  1. Given each source and data type, when a cycle completes, then success, error and skipped counts plus duration percentiles are recorded.
  2. Given a source's success rate drops below its configured objective over a rolling window, when the check runs, then an alert fires naming the source and the dominant error.
  3. Given an SLO and its error budget are defined per source, when budget consumption exceeds a threshold, then further risky changes to that source's remote are gated.
  4. Given all sources are healthy, when the status surface is rendered, then "all systems green" reflects measured objectives rather than a static string.
  5. Given a source produces zero attempts in a cycle, when the check runs, then that is alerted as a scheduling failure, not treated as healthy.
Implementation notes

All the raw material is already in scrapingStatuses (per-source, per-type status with updatedAt); this proposes rolling it up, defining objectives and wiring alerts, sharing a surface with the ingest metrics in INGS-14 and the freshness SLAs in INGS-18.

Edge cases & risks

The landing page already asserts "Status: all systems green" with nothing behind it — publishing real numbers is a commercial decision as much as an engineering one, and criterion 5 matters because the most common outage mode is a task that stopped being scheduled at all.

SCRP-15

Detect blocks and rotate a health-checked proxy pool

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want proxies health-checked and rotated, with automatic cool-down when a source starts blocking us, so that a burst of failures does not escalate into a long-term ban.
Acceptance criteria
  1. Given a pool of proxy endpoints, when a request is dispatched, then a healthy proxy is selected rather than a single fixed host.
  2. Given a proxy's failure rate exceeds a threshold, when health is evaluated, then it is removed from rotation and re-probed before returning.
  3. Given responses indicating a block — CAPTCHA markup, a challenge page, or a sustained run of 403/429 — when they are detected, then the source enters cool-down and concurrency is reduced.
  4. Given cool-down is active for a source, when the periodic task fires, then it skips that source and records the reason instead of hammering it.
  5. Given a block is detected, when the affected records are updated, then they are marked as blocked rather than as data errors so they are not misdiagnosed as parser failures.
Implementation notes

Replaces the single shared undici ProxyAgent built from PROXY_HOST/PROXY_USERNAME/PROXY_PASSWORD with a pool abstraction, and adds block classification to checkData across the Google, Booking and TripAdvisor remotes.

Edge cases & risks

Block pages often return HTTP 200 with challenge HTML, so detection must be content-based; without criterion 5 a block wave looks exactly like a parser regression and sends engineers to the wrong place.

SCRP-16

Catch parser regressions with golden-file tests

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want recorded upstream payloads replayed through the parsers on every build, so that a mapper change or an upstream change is caught in CI rather than in the catalogue.
Acceptance criteria
  1. Given recorded fixtures per source, when the test suite runs, then each parser and mapper is asserted against an expected canonical output.
  2. Given a change alters parser output, when CI runs, then the diff is shown and the build fails until the golden file is updated deliberately.
  3. Given fresh live payloads are captured periodically, when they diverge structurally from the fixtures, then an upstream-change alert is raised separately from the CI failure.
  4. Given a fixture contains reviewer names or other PII, when it is committed, then it is redacted first.
Implementation notes

Extends the existing GoogleTravelHtmlParser and BookingScraper unit tests to cover the Booking mapper's 684 lines, the TripAdvisor persisted-query responses and the FeaturesMapGoogle.ts mapping path.

Edge cases & risks

Fixtures freeze a moment in time, so passing tests can coexist with a live integration that has been broken for weeks — criterion 3 is what makes this more than a refactoring safety net.

SCRP-17

Drive re-scrape cadence from subscription and volatility

Proposed
Revenue managerShould
As a Revenue manager, I want the hotels I actively watch refreshed more often than the long tail, so that the data behind my comp set reflects what is happening this week.
Acceptance criteria
  1. Given a hotel is on a subscribed list, when cadence is computed, then its refresh interval is shorter than the default 30-day staleness threshold.
  2. Given a hotel's content has been observed to change rarely, when cadence is computed, then its interval lengthens.
  3. Given total capacity is fixed, when cadence is applied, then the aggregate scheduled volume stays within the configured batch and concurrency budget.
  4. Given a hotel loses its subscription, when cadence is recomputed, then it reverts to the default cadence without losing its existing data.
  5. Given cadence data is unavailable, when work is selected, then the existing staleTimeThresholdMs behaviour applies unchanged.
Implementation notes

Would replace the flat staleTimeThresholdMs in getStaleHotels with a per-hotel interval informed by rich-content subscription state (AccessManager.isRichContentSubscribed, POST /orgs/:orgId/subscribed-hotels) and observed change rates from the versioning in LINK-20.

Edge cases & risks

Prioritising subscribed hotels starves the long tail, which is what customers are actually buying when they upload a fresh list; the capacity ceiling in criterion 3 is the only thing keeping the two from competing destructively.

SCRP-18

Track cost per scraped record

Proposed
Ubio finance opsShould
As a Ubio finance ops, I want proxy bandwidth, third-party API calls and compute attributed per source and per record, so that product pricing reflects what the data actually costs to keep fresh.
Acceptance criteria
  1. Given a scrape completes, when cost is attributed, then proxy bytes, request count and duration are recorded against the source and hotel.
  2. Given billable third-party calls such as Google Places lookups from the link CLI, when they are made, then they are counted against the same cost model.
  3. Given a reporting period, when I run the report, then cost per successfully refreshed record is shown per source.
  4. Given a source's cost per record exceeds a configured ceiling, when the report runs, then it is flagged for review.
Implementation notes

Complements the existing internal usage picture in accessLogs and GET /stats/, which measures what customers consume but says nothing about what supply costs.

Edge cases & risks

Failed and blocked attempts cost the same as successful ones, so a cost-per-success metric that ignores failures will understate the true cost of the hardest sources by a wide margin.

SCRP-19

Move hard-coded secrets out of the codebase

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want every secret supplied by configuration and rotatable, so that repository access does not equal production data access.
Acceptance criteria
  1. Given the huId salt 31b54e6e4b#, when ids are generated, then the salt is read from configuration rather than source code.
  2. Given the AES-256-CBC image URL key, when URLs are encoded or decoded, then the key is read from configuration.
  3. Given a key is rotated, when previously issued values are read, then the previous key is still accepted for a defined migration window.
  4. Given a required secret is missing, when the service boots, then it fails fast rather than starting with a default.
  5. Given secrets have been in version control, when the change ships, then rotation is treated as mandatory rather than optional.
Implementation notes

Both values live in packages/shared today; mesh-config already supplies every other secret in the platform (JWT_PRIVATE_KEY, STRIPE_*, GOOGLE_PLACES_API_KEY, PROXY_*), so the pattern exists.

Edge cases & risks

The huId salt cannot truly be rotated without invalidating every customer-held id, so criterion 3 is really a permanent dual-read — which is another argument for the allocated identifier in LINK-11.

SCRP-20

Review robots, terms and legal risk per source

Proposed
Ubio adminMust
As a Ubio admin, I want a documented legal position for each extraction source, so that the business understands its exposure before we sell content derived from it.
Acceptance criteria
  1. Given each source, when the review is recorded, then it states what is retrieved, on what basis, and what technical measures are used.
  2. Given a source's terms prohibit an access method we currently use, when the review runs, then the risk is recorded with an owner and a mitigation decision.
  3. Given a source is added or its access method changes materially, when the change is proposed, then the review is updated before the change ships.
  4. Given a source is assessed as unacceptable, when the decision is taken, then the corresponding task is disabled and the coverage impact is quantified for customers.
Implementation notes

The current methods that need positions taken on them include spoofed user agents and generated cookies for Google Travel, the Booking.App/47.1 iOS/17.5 mobile identity, TripAdvisor persisted queries with an API key, and the redistribution of review text through the reviews export.

Edge cases & risks

Reviewer PII is already stripped from HotelReviewExport, which shows the concern is understood in one place but not systematically; the commercial risk is concentrated in exactly the sources that supply the most differentiated content.

SCRP-21

Emit structured logs with trace ids across the pipeline

Proposed
Ubio support agentShould
As a Ubio support agent, I want to follow one hotel or one request through scrape, merge and API response, so that I can answer "why is this field wrong?" without an engineer reading raw logs.
Acceptance criteria
  1. Given any pipeline operation, when it logs, then the entry is structured JSON including trace id, huId where known, dsName, task and outcome.
  2. Given a scrape leads to a canonical recompute, when both log, then they share the same trace id.
  3. Given a customer-reported huId, when I search the logs, then I can retrieve its most recent scrape, merge and error events.
  4. Given a log entry would include a secret, a bearer token or reviewer PII, when it is written, then those values are redacted.
Implementation notes

Would build on the @ubio/framework logger already used across the API and scrapers, propagating a trace id from the periodic task through HotelDataUpdateService into canonicalData6 writes.

Edge cases & risks

At 50-way concurrency across four tasks, per-record logging volume is substantial, so sampling policy must be decided up front — and criterion 4 matters because scraped payloads routinely contain both credentials in URLs and reviewer names.

SCRP-22

Load and chaos test the export and search paths

Proposed
OTA product managerShould
As an OTA product manager, I want evidence that search and export hold up under my volumes, so that I can commit to shipping a hotel surface on this platform.
Acceptance criteria
  1. Given a representative query mix, when the load test runs against GET /search, then latency percentiles are reported against the sub-second target.
  2. Given concurrent exports of large lists, when the test runs, then the streaming pipeline holds constant memory and completes within its documented window.
  3. Given a dependency is degraded — Atlas Search slow, GCS unavailable, METASEARCH_SERVICE_URL timing out — when the chaos scenario runs, then the API degrades with correct status codes rather than hanging.
  4. Given a scraping cycle runs concurrently with peak API load, when the test runs, then API latency stays within its objective.
  5. Given any test breaches its threshold, when results are published, then the breach is recorded against the SLOs in SCRP-14 rather than noted informally.
Implementation notes

Targets the paths with known scale sensitivity: Atlas searchAfter pagination with $searchMeta counts and its estimatedDocumentCount fallback, the Readable.from(dbStream) → transform → toJsonArray → gzip → GCS export pipeline, and the full-collection-scan org lookup on the Stripe webhook.

Edge cases & risks

Scrapers and the API share the same MongoDB, so the realistic worst case is a scraping burst and an export of a million-hotel list colliding — a scenario nothing currently tests.

Part 11

Security, performance & quality

SEC 5 built6 partial11 proposed

Security, privacy & compliance

Hotel Universe holds a customer's supply file, their comp sets, their price positions and their team's identities, and it hands all of it out over a single bearer credential. What exists today is a coherent core — hashed PAT storage, signed JWTs, an org filter on nearly every query — sitting next to a set of surfaces that were never wired to the auth stack at all. This epic covers the controls that are already in place, the specific holes that a security questionnaire will find first, and the compliance evidence an OTA or corporate buyer will ask for before signing.

SEC-01

Authenticate every request with a bearer credential

Built
DeveloperMust
As a Developer, I want one consistent bearer-token scheme across the whole API, so that I can wire a client without learning two authentication models.
Acceptance criteria
  1. Given I call any authenticated route with Authorization: Bearer <JWT>, when the framework resolves the principal, then the request proceeds with tokenType and principal populated from the decoded token.
  2. Given I send Authorization: Bearer pat_…, when the value is SHA-256 hashed and matched against accessTokens, then the principal resolves as principal.type: serviceAccount with displayName: "PAT <name>" and the org and scopes taken from the token record.
  3. Given I send no Authorization header on an authenticated route, when the request is handled, then it is rejected with 401 and no data is returned.
  4. Given I send a pat_ value with no matching hash, when lookup fails, then the response is 401 and the failed value is never echoed back in the error body.
  5. Given the route is GET /status, when I call it with no credential, then it returns {version, service, timestamp} and nothing else.
Implementation notes

Auth is resolved in the @ubio/framework v17 auth layer: pat_ prefix routes to a SHA-256 lookup in accessTokens, everything else is verified as a JWT against JWT_PUBLIC_KEY / JWT_ISSUER.

Edge cases & risks

There is no support for anything other than a bearer header — no mTLS, no signed requests, no IP allowlist — so a leaked PAT is full access until someone notices and deletes it. Because the same header carries two credential types, a client that accidentally sends a JWT where a PAT is expected gets a confusingly generic 401.

SEC-02

Issue a personal access token whose secret I only see once

Built
AI agent builderMust
As an AI agent builder, I want to mint an API key for my agent and have the platform store only a hash of it, so that a database leak does not hand an attacker working credentials.
Acceptance criteria
  1. Given I have the pat:manage scope, when I POST /tokens with a name, then the response contains {secret, accessToken} where secret is prefixed pat_ and is the only time the plaintext exists in a response.
  2. Given the token has been created, when I GET /tokens, then I see name, createdAt and createdBy but never the secret or anything that can be reversed into it.
  3. Given I close the creation dialog without copying, when I return to /tokens, then there is no way to recover the secret and the UI has warned me with "Make sure to copy the token now…".
  4. Given I DELETE /tokens/{id}, when a client next presents that secret, then the hash lookup misses and the request is rejected with 401.
  5. Given my credential is a PAT rather than a JWT, when I call POST /tokens, then the request is refused because PAT scopes deliberately exclude pat:manage — a token cannot mint another token.
Implementation notes

accessTokens {_id, name, hash (sha256), scopes[], createdAt, createdBy, orgId}; portal screen is views/AccessTokens.vue with a copy button and a access_token_created telemetry event.

Edge cases & risks

The token table shows an "Expiration Time" column that is always empty because PATs never expire (see SEC-13). Deleting a token is the only revocation mechanism, and there is no usage timestamp to tell an admin whether a token is still in use before they delete it.

SEC-03

Sign portal sessions with a configurable-expiry JWT

Built
Organisation adminMust
As an Organisation admin, I want portal sessions to be signed tokens with a bounded lifetime, so that a token copied off a laptop stops working rather than lasting forever.
Acceptance criteria
  1. Given a user completes POST /auth/google, when the JWT is minted, then it is signed with JWT_PRIVATE_KEY, carries JWT_ISSUER, and expires after JWT_TOKEN_EXPIRATION_SECONDS (currently 604800, seven days).
  2. Given a token has passed its expiry, when it is presented on any authenticated route, then verification fails and the response is 401.
  3. Given the portal is open, when the five-minute refresh interval fires, then POST /auth/refresh returns a fresh token and the expiry window restarts.
  4. Given a user calls POST /auth/switch-org with an orgId they are not a member of, when membership is checked, then the call is refused with 403 and the existing token is unchanged.
  5. Given the signing key is rotated, when an old token is presented, then verification fails cleanly rather than throwing an unhandled error.
Implementation notes

JWT_* env vars on the API; portal side is ApiManager.startTokenRefreshInterval() with ubio_last_token_refresh in localStorage and the token itself under ubio_auth_token.

Edge cases & risks

A seven-day token in localStorage is readable by any XSS on the portal origin, and there is no server-side session list, so a stolen token cannot be revoked before it expires — logging out only clears the browser copy.

SEC-04

Scope every read and write to one organisation

Built
OTA product managerMust
As an OTA product manager, I want absolute certainty that another customer cannot see my supply file, my lists or my price queries, so that I can put competitively sensitive data into the platform.
Acceptance criteria
  1. Given any request that touches org-owned data, when the handler resolves the tenant, then getOrgId() returns the org from the principal and throws if none is present, so a query can never run unscoped.
  2. Given I request GET /lists/{listId} for a list belonging to another organisation, when checkOrgMembership runs, then the response is 403 and no list metadata leaks in the error.
  3. Given I create a list, an export or an access token, when the document is written, then orgId is persisted on it and every subsequent read filters on it.
  4. Given I switch organisation in the portal, when the new token is issued and the page reloads, then all list, export, usage and token screens show only the new org's data.
  5. Given a PAT is presented, when the org is resolved, then it comes from the accessTokens record rather than from any request parameter, so an org id in a query string cannot widen access.
Implementation notes

Applies across lists, listEntries, exports, accessTokens, invitations and accessLogs (meta.orgId); GET /stats/ is the notable case where ubio users bypass the membership check by design.

Edge cases & risks

The guarantee is per-handler rather than enforced at the repository layer, so a new endpoint that forgets the check inherits no protection — this is exactly how POST /sources and /scrapers/* ended up open. Canonical hotel data is deliberately shared across tenants, so the org filter only protects derived artefacts, not the underlying catalogue.

SEC-05

Strip reviewer identity out of review exports

Built
Customer data engineerMust
As a Customer data engineer, I want exported reviews to arrive without reviewer names attached, so that I am not importing third-party personal data into my warehouse.
Acceptance criteria
  1. Given a list export of type reviews, when each HotelReview is serialised, then the reviewer field is dropped and the exported record carries only source, reviewDate, overallRating, original and translation.
  2. Given I download the gzipped export, when I inspect any record, then no reviewer.name value appears anywhere in the file.
  3. Given the same review is read through GET /hotels/{huId}/reviews in the portal, when it renders, then the display path is a separate schema from the export path and the export schema remains the PII-stripped one.
  4. Given a review has no reviewer at all, when it is exported, then the record is still emitted rather than skipped.
Implementation notes

HotelReviewExport in the shared schema package is the export projection of HotelReview; the export pipeline emits one HotelReviewExport per review from canonicalReviewData1.

Edge cases & risks

Free-text review bodies can still contain names, emails and room numbers written by the reviewer, so stripping the structured field is necessary but not sufficient for a GDPR argument. Nothing prevents the same PII reaching a customer through the AI review summary path.

SEC-06

Keep the runtime and outbound connections locked down

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the deployed service to run with least privilege and to verify the certificates it talks to, so that a compromised dependency or a hostile proxy cannot escalate.
Acceptance criteria
  1. Given the API container starts, when the process is inspected, then it runs as non-root uid 1001 on Node 22 alpine listening on port 8080.
  2. Given a browser on an origin not listed in CORS_ORIGIN, when it issues a cross-origin request, then the preflight is rejected and the browser blocks the response.
  3. Given the portal origin is listed, when it calls the API with the Authorization header, then the request succeeds.
  4. Given a scraper makes an outbound request through PROXY_HOST, when the TLS handshake happens, then the certificate chain must be verified — today rejectUnauthorized: false is set on the undici ProxyAgent, which is the named gap.
  5. Given certificate verification is enabled and the proxy presents an invalid certificate, when the request runs, then it fails loudly with a logged error rather than silently continuing over an unverified channel.
Implementation notes

Images are built by GitHub Actions to eu.gcr.io/automation-cloud-registry; proxy configuration is PROXY_HOST / PROXY_USERNAME / PROXY_PASSWORD shared by the Google Travel, Booking and TripAdvisor remotes.

Edge cases & risks

rejectUnauthorized: false means any party able to intercept the proxy path can read and rewrite scraped hotel content and rate data, which then flows straight into sourceHotelData5 and the canonical merge. Turning verification on may break against proxy vendors using self-signed certificates, so it needs a pinned CA rather than a flag flip.

SEC-07

Expire access logs after a year

Partial
Ubio adminShould
As a Ubio admin, I want per-hotel access records to age out automatically, so that we are not holding a permanent record of every customer's commercial interest.
Acceptance criteria
  1. Given a request logs an action such as hotels:view or metasearch:view, when the row is written, then it lands in the accessLogs time-series collection with time, principalId, huId and meta:{orgId, action}.
  2. Given a row is older than one year, when the TTL sweep runs, then the row is deleted without any manual intervention.
  3. Given a customer asks how long we keep usage data, when we answer, then the retention period is documented and matches the configured TTL — today the one-year value is hard-coded rather than configurable per tenant, which is the named gap.
  4. Given trial quota is enforced from "all-time distinct hotels viewed", when logs older than a year are deleted, then the quota calculation must not silently reset a trial user's allowance — the interaction is currently undefined.
Implementation notes

accessLogs is a MongoDB time-series collection (timeField: time, metaField: meta, granularity hours) with a one-year TTL; AccessTracking writes it and GET /stats/ aggregates distinct huId per action.

Edge cases & risks

ACCESS_TIER_TRIAL_LIMIT counts distinct hotels from these logs, so TTL expiry is a quota-reset side channel for a long-lived trial account. There is no shorter retention option for customers who ask for one, and no way to purge a single org's logs on request.

SEC-08

Close the endpoints that have no authentication at all

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want the scraper, source-update and image-resize routes to require a credential, so that an anonymous caller cannot drive our infrastructure or write into our data.
Acceptance criteria
  1. Given an anonymous caller hits GET /scrapers/google, GET /scrapers/booking or GET /scrapers/booking/rooms, when auth is enforced, then the response is 401 — today all three execute a live scrape for anyone who knows the URL.
  2. Given an anonymous caller hits POST /sources, when auth is enforced, then the response is 401 — today the handler has no auth check and accepts source-data updates that flow into sourceHotelData5 and the canonical merge.
  3. Given the scraper routes are authenticated, when an internal service calls them with a service credential, then the scrape proceeds unchanged.
  4. Given GET /img/resize?hash&w&h is called with an arbitrary hash, when the request is validated, then only hashes the platform itself issued are honoured, so the resizer cannot be used as an anonymous open image proxy or a CPU-burn target.
  5. Given a caller floods any of these routes, when the rate limiter is extended to cover them, then further requests return 429 with Retry-After.
Implementation notes

The scraper classes are registered in the API app to power /scrapers/*; the img package exposes a single sharp-backed GET /img/resize with no auth; POST /sources sits in the api package alongside authenticated routes but skips the check.

Edge cases & risks

/scrapers/* is the highest-severity of the three: it lets an anonymous party use our proxy pool and spoofed Booking/TripAdvisor credentials at our cost and under our IP reputation, and a ban lands on us. Unauthenticated POST /sources is a data-integrity hole — a poisoned source document can overwrite canonical fields for a hotel, subject only to the DS_NAMES priority order.

SEC-09

Make the invitation link safe to put in an email

Partial
Organisation adminMust
As an Organisation admin, I want an invitation that cannot be redeemed by anyone who happens to see the link, so that a forwarded email does not hand a stranger access to my organisation's data.
Acceptance criteria
  1. Given I invite alice@example.com, when the SendGrid email is sent, then it links to ${FRONTEND_URL}/invite/accept?token=<id> and the invitation expires after seven days.
  2. Given the invitee opens the link, when POST /invitations/{id}/accept runs, then it succeeds without a prior login and returns {success, message, orgId, orgName, userExists} — this unauthenticated-by-design behaviour is what needs replacing.
  3. Given the fix ships, when the link is opened, then the invitee must first authenticate with Google and the accepted invitation is only redeemable by the email address it was addressed to.
  4. Given a third party obtains the link, when they sign in with a different email and attempt to accept, then the call is refused with 403 and the invitation stays pending.
  5. Given the invitation has expired or has already been accepted, when the link is opened again, then a clear error state renders in InviteAccept.vue rather than a generic failure.
Implementation notes

The invitation id is the bearer token; invitations {id, email, orgId, invitedBy, invitedByName, createdAt, expiresAt}; if the SendGrid send throws, the invitation record is deleted. The only email the platform sends is sendInvitationEmail.

Edge cases & risks

Because the invitation id is both the database key and the credential, it appears in server logs, referrer headers and any mail-scanning proxy in the recipient's path. Ids are also enumerable if the generator is not cryptographically random, and there is no rate limit on the accept endpoint to make guessing expensive.

SEC-10

Fix the permission checks that guard the wrong thing

Partial
Organisation adminMust
As an Organisation admin, I want each administrative action guarded by the permission that names it, so that giving someone list access does not quietly give them control of my membership.
Acceptance criteria
  1. Given invitation endpoints (POST /invitations, GET /invitations, DELETE /invitations/{id}), when the scope check runs, then it must require ORGS_MANAGE — today it requires LISTS_MANAGE, which every member and every PAT already holds.
  2. Given a PAT is presented (PAT scopes are exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view), when it calls POST /invitations, then after the fix it is refused with 403 because PATs do not carry orgs:manage.
  3. Given the users:manage permission is declared in the permission list, when it is not enforced anywhere, then it is either wired to the member-management routes or removed so the declared surface matches reality.
  4. Given the internal super-user check isUbee(), when it resolves, then it must consult an explicit flag on the users record rather than testing whether the email string contains @ub.io (the portal additionally accepts ubio.ai).
  5. Given a user signs up with an address such as attacker@notub.io.example.com, when isUbee() runs today, then the substring match can succeed and grant GET /orgs across every tenant plus the /stats membership bypass — the fix must make this case fail.
Implementation notes

Scopes are orgs:manage, users:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage, exports:manage, with orgs:members:manage and orgs:members:view commented out; the portal mirrors the same domain check in AccessManager.isUbioUser() / ensureUbioAccessResolved() to gate requiresUbioAccess routes.

Edge cases & risks

GET /orgs returns every organisation on the platform, so the super-user check is the single most valuable authorisation boundary in the system and it is a string containment test. Tightening invitation scope will break any existing automation that invites members with a PAT, so it needs a migration note.

SEC-11

Stop swallowing Stripe webhook failures

Partial
Ubio finance opsMust
As a Ubio finance ops, I want a failed webhook to be visible and retried, so that a customer who has paid is not left on the trial tier with no trace of why.
Acceptance criteria
  1. Given Stripe posts to POST /billing/webhook, when the stripe-signature header is verified, then an invalid signature is rejected before any handler runs.
  2. Given a handled event (checkout.session.completed, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed) throws during processing, when the error is caught, then it must be logged with the event id and the response must be a non-2xx so Stripe retries — today every path returns 200 and the error is discarded.
  3. Given an unhandled event type arrives, when it is ignored, then the ignore is recorded at debug level so we can see what we are dropping.
  4. Given a webhook fails repeatedly, when the failure count crosses a threshold, then an alert routes to finance ops with the organisation id.
  5. Given the same event is delivered twice, when it is processed, then the tier change is idempotent and does not double-apply.
Implementation notes

BillingService maps status === 'active' to tier FULL and everything else to TRIAL; the code carries a TODO — "Decide whether / how much to gate based on tier / subscription plan" — and all three of pro|pro_plus|ultra currently grant identical access.

Edge cases & risks

A swallowed checkout.session.completed means a paying customer stays on TRIAL and hits the 100-hotel limit with a 402 telling them to contact sales, which is the worst possible first impression. A swallowed customer.subscription.deleted is the opposite failure: continued FULL access after cancellation, with no reconciliation job to catch it.

SEC-12

Move hard-coded secrets into a managed store and rotate them

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want every key the platform uses to live in a secret manager with a rotation procedure, so that a repository leak is not a permanent compromise.
Acceptance criteria
  1. Given the codebase is scanned, when the scan completes, then no cryptographic material appears in source — specifically the huId salt used in sha1('31b54e6e4b#' + dsHotelId) and the AES-256-CBC key used by encodeImageUrls.
  2. Given the service starts, when it loads configuration, then keys are read from the secret manager at boot and are absent from the container image and from any committed .env.
  3. Given a key is rotated, when the rotation runs, then both the previous and current key are accepted for a defined overlap window so in-flight tokens and already-encoded image URLs keep working.
  4. Given the image key is rotated, when an old images.automation.cloud/img?hash=<ciphertext>.<iv> URL is requested, then it still resolves during the overlap window and is re-encoded lazily.
  5. Given a rotation is attempted with the overlap window disabled, when the change is applied, then the deployment is blocked with an explicit error rather than silently invalidating every existing URL.
Implementation notes

Builds on the existing env surface (JWT_PRIVATE_KEY, GCS_SERVICE_ACCOUNT_KEY, STRIPE_*, SENDGRID_API_KEY, METASEARCH_SERVICE_TOKEN, BOOKING_*, TRIPADVISOR_*) which is already externalised — the work is pulling the two in-source constants out and adding a key-version field.

Edge cases & risks

The huId salt cannot be rotated at all without regenerating every huId in canonicalData6 and every customer's stored ids, so in practice it must be treated as a permanent secret and protected accordingly. Rotating the image key without an overlap window breaks every image URL already delivered in an export.

SEC-13

Give access tokens an expiry and a real scope

Proposed
AI agent builderMust
As an AI agent builder, I want to issue a read-only token that expires, so that an agent I hand to a colleague cannot create lists or run up metasearch spend forever.
Acceptance criteria
  1. Given I POST /tokens, when I include an expiresAt and a scope subset, then the token record stores both and the "Expiration Time" column in views/AccessTokens.vue finally shows a value.
  2. Given a token has passed expiresAt, when it is presented, then the request is rejected with 401 and the token is shown as Expired in the portal rather than deleted.
  3. Given I create a token scoped to hotels:view and reviews:view only, when it calls POST /lists/fromIds, then the response is 403.
  4. Given I request a scope my own principal does not hold, when the token is created, then the request is refused — a token can never be broader than its creator.
  5. Given a token is within seven days of expiry, when the org admin opens the tokens screen, then it is flagged so the key can be rotated before an agent breaks.
Implementation notes

accessTokens already has a scopes[] array that is populated with a fixed list; this story makes it caller-selectable and adds expiresAt plus a lastUsedAt stamp. It delivers the landing page's existing promise: "Scoped API keys — issue read-only tokens per agent or per workspace".

Edge cases & risks

Expiring tokens will break long-lived MCP configurations silently unless the 401 body is specific enough for @hoteluniverse/mcp to surface a useful message. Narrowing default scopes is a breaking change for existing integrations and must apply only to newly created tokens.

SEC-14

Assign per-user roles inside an organisation

Proposed
Organisation adminMust
As an Organisation admin, I want to make some colleagues viewers and others administrators, so that an analyst cannot delete lists, remove members or change our billing plan.
Acceptance criteria
  1. Given I open /members, when I view a member row, then I can set a role from Owner, Admin, Member and Viewer, and the change takes effect on that user's next token refresh.
  2. Given a Viewer signs in, when they load a list, then read operations succeed and DELETE /lists/{listId}, POST /lists/{listId}/exports and PUT /orgs/{id} all return 403.
  3. Given a Member attempts to open /billing and start a plan change, when the role check runs, then the action is refused and the screen explains that billing is Owner-only.
  4. Given the last Owner attempts to demote themselves, when the change is submitted, then it is refused with the same class of guard as "Cannot remove the last member of an organization".
  5. Given a role is changed, when the audit log is inspected, then the actor, subject, old role and new role are recorded.
Implementation notes

Today membership is a flat organizations.memberIds[] and every member receives the identical JWT scope set (orgs:manage, exports:manage, lists:manage, hotels:view, reviews:view, metasearch:view, pat:manage). Roles would map to scope sets at token-mint time, reusing the existing scope enforcement and finally giving users:manage and the commented-out orgs:members:* scopes a purpose.

Edge cases & risks

Every existing member currently has admin-equivalent power, so a migration that demotes people will generate support tickets unless existing members are grandfathered as Admin. Role changes only bite when the JWT is re-minted, so the refresh interval defines the worst-case window for a revoked privilege.

SEC-15

Keep an immutable audit log of security-relevant events

Proposed
Corporate travel buyerShould
As a Corporate travel buyer, I want an exportable record of who did what in our workspace, so that I can satisfy my own internal audit before approving the vendor.
Acceptance criteria
  1. Given any of sign-in, org switch, member invite or removal, token create or delete, role change, plan change, list deletion or export download occurs, when it completes, then an append-only audit record is written with actor, org, action, target, timestamp, IP and user agent.
  2. Given an audit record exists, when anyone attempts to update or delete it through the API, then the operation is refused — the store is append-only with a defined retention beyond the one-year accessLogs TTL.
  3. Given I am an Organisation admin, when I open the audit screen, then I can filter by actor and date range and export the result as CSV.
  4. Given an action is performed by a PAT rather than a person, when the record is written, then the actor is the token name and its creator, not just "serviceAccount".
  5. Given an action fails authorisation, when the 403 is returned, then the denied attempt is also recorded.
Implementation notes

Distinct from accessLogs, which records data access (hotels:view, export:create:*) for quota purposes; this is an administrative trail. It could reuse the time-series pattern with a different metaField and a longer retention.

Edge cases & risks

Audit logs themselves become a PII store (IPs, emails) and need their own retention and erasure story. Writing an audit record on every 403 makes the log a useful map of the permission model for anyone who can read it, so the log must be role-gated.

SEC-16

Handle data-subject access and erasure requests

Proposed
Ubio support agentMust
As a Ubio support agent, I want a defined procedure and tooling for a GDPR access or erasure request, so that I can answer within the statutory window without a platform engineer writing ad-hoc scripts.
Acceptance criteria
  1. Given a data subject identified by email, when I run the export tool, then I receive every record referencing them: their users document, organizations.memberIds membership, invitations, accessTokens.createdBy, accessLogs.principalId and audit entries.
  2. Given an erasure request is approved, when it is executed, then the users record is deleted or irreversibly pseudonymised and all references are updated, while org-owned artefacts they created (lists, exports) survive with an anonymised creator.
  3. Given the subject is the last member of an organisation, when erasure runs, then the operation halts with an explicit decision point rather than orphaning the org and its billing relationship.
  4. Given erasure completes, when the same person signs in again with Google, then a fresh User is created with no link to the erased record.
  5. Given a request concerns a hotel reviewer rather than a platform user, when it is assessed, then the process documents that reviews are third-party source data and how a takedown propagates into canonicalReviewData1 and already-delivered exports.
Implementation notes

Builds on the known collections (users, organizations, invitations, accessTokens, accessLogs, lists, exports) plus the GCS export bucket, where objects live 30 days under the lifecycle rule and 7 days more under soft delete.

Edge cases & risks

Exports already downloaded by a customer are outside our control, so the erasure statement must be scoped honestly. Stripe holds customer and invoice data with its own retention that we cannot erase on request.

SEC-17

Offer a choice of data residency

Proposed
Corporate travel buyerCould
As a Corporate travel buyer, I want my organisation's data to stay in a region I nominate, so that I can sign without an exception from my own compliance team.
Acceptance criteria
  1. Given I am creating an organisation, when I choose a residency region, then all org-owned data — lists, entries, exports, access logs, audit records — is stored in that region.
  2. Given my region is EU, when an export is produced, then the GCS object is written to a European bucket (today hotel-universe-exports-dev in europe-west1) and the signed URL is regional.
  3. Given a request arrives at a non-home region, when it is routed, then it is served from the home region rather than replicating data outward.
  4. Given residency is not selectable for a tier, when I open the setting, then the UI states which plan is required instead of silently defaulting.
  5. Given an org is created without an explicit choice, when the default applies, then the default region is documented and shown in the organisation settings screen.
Implementation notes

The canonical hotel catalogue is not customer data and can stay global; the work is regionalising the org-scoped collections and the GCS bucket, and pinning the metasearch proxy target per region.

Edge cases & risks

Regionalisation multiplies the deployment surface and every operational runbook, and it interacts badly with the current single-Mongo assumption throughout the repositories. Stripe, SendGrid and the scraper proxy vendors each have their own regional footprint that residency claims must account for.

SEC-18

Publish a DPA, sub-processor list and encryption-at-rest statement

Proposed
OTA product managerShould
As an OTA product manager, I want the legal and security paperwork available before I ask for it, so that procurement is not the slowest part of the deal.
Acceptance criteria
  1. Given I visit the trust page, when it loads, then I find a downloadable DPA, a dated sub-processor list and a statement of encryption in transit and at rest.
  2. Given the sub-processor list is published, when I read it, then it names Google Cloud Storage, MongoDB Atlas, Stripe, SendGrid, Google OAuth and Places, the metasearch service and the scraper proxy vendors, each with purpose and region.
  3. Given a sub-processor is added or removed, when the change is published, then subscribed customers receive notice before it takes effect.
  4. Given the encryption statement is published, when it describes storage, then it states what Atlas and GCS encrypt by default and explicitly notes that hotel image URLs are additionally AES-256-CBC encoded.
  5. Given a customer asks a question the page does not answer, when they use the contact route, then it reaches hello@ubio.ai with the existing "Talk to sales" path rather than a dead form.
Implementation notes

Sits alongside the existing landing footer ("About Ubio Ltd.", "Status: all systems green") and the docs site at https://docs.hotel-universe.travel/guide/.

Edge cases & risks

A published sub-processor list is a commitment to keep it current; a stale list is worse than none in an audit. Naming the scraper proxy vendors publicly has commercial and anti-blocking consequences and may need to be described by category instead.

SEC-19

Collect SOC 2 evidence continuously

Proposed
Ubio adminCould
As a Ubio admin, I want control evidence gathered automatically as we work, so that an audit window is a report rather than a three-month scramble.
Acceptance criteria
  1. Given the control set is defined, when evidence is collected, then access reviews, change management, backup verification and incident records are captured from the systems that already produce them (GitHub Actions, the audit log, restore drills).
  2. Given a quarterly access review is due, when it opens, then every organisation's member list and every active PAT is presented for confirmation and the outcome is recorded.
  3. Given a production deploy happens, when it is recorded, then the tag, the image digests for hotel-universe-api and hotel-universe-scraping-service, the approver and the dispatched deploy to ubio/infrastructure are linked as one change record.
  4. Given a control has no evidence for the period, when the dashboard renders, then it is flagged as failing rather than omitted.
Implementation notes

Leans on the existing CI/CD (tag push → image build → repository dispatch) and on SEC-15's audit log as the primary evidence sources.

Edge cases & risks

Evidence collection is worthless if the underlying controls are the ones described in SEC-08 and SEC-10 — pursuing the certificate before fixing the open endpoints would be an expensive way to document a weak posture.

SEC-20

Run a penetration test and open a bug bounty

Proposed
Ubio adminShould
As a Ubio admin, I want an external party actively looking for holes, so that the next POST /sources is found by someone who tells us.
Acceptance criteria
  1. Given a test is scoped, when it runs, then it covers the API, the portal, the image service and the MCP server, with credentialed tenants on both TRIAL and FULL tiers.
  2. Given a finding is raised, when it is triaged, then it gets a severity, an owner and a remediation deadline, and criticals block the next release.
  3. Given remediation is claimed, when the tester retests, then the fix is verified before the finding closes.
  4. Given a researcher reports a vulnerability outside a test window, when they use the published security.txt contact, then they receive an acknowledgement within a stated SLA and safe-harbour terms.
  5. Given a report duplicates a known issue, when it is closed, then the researcher is told which finding it duplicates rather than receiving silence.
Implementation notes

Scope should explicitly include multi-tenancy: attempt cross-org reads on GET /lists/{listId}, GET /stats/, GET /orgs and export download URLs, plus the unauthenticated surfaces in SEC-08.

Edge cases & risks

A bug bounty against a system with known-open endpoints will generate a flood of duplicate reports and payouts for issues we already have on the backlog — fix SEC-08 and SEC-10 first. Testing the scrapers against live Booking and TripAdvisor risks account bans.

SEC-21

Scan dependencies and container images in CI

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want vulnerable packages and base images caught at build time, so that we are not shipping a known CVE to production on a tag push.
Acceptance criteria
  1. Given a pull request is opened, when CI runs, then a dependency audit runs across the monorepo packages (api, docs, img, import, link, scrapers, shared) and the portal.
  2. Given a critical or high advisory affects a production dependency, when the check completes, then the build fails with the package, the advisory and the fixed version.
  3. Given a tag is pushed, when the hotel-universe-api and hotel-universe-scraping-service images are built, then each is scanned before push to eu.gcr.io/automation-cloud-registry and a failing scan blocks the push.
  4. Given a finding has no fix available, when it is triaged, then it can be time-boxed with a recorded expiry, and the suppression itself expires rather than living forever.
  5. Given the Node 22 alpine base image goes out of support, when the scan runs, then it is reported as a finding.
Implementation notes

Extends the existing GitHub Actions workflows that already build and dispatch; sits next to the eslint/biome and preversion lint gates already in place.

Edge cases & risks

sharp, undici, csv-parse and the Stripe SDK are the highest-blast-radius dependencies here. A hard failure gate on every advisory will stall unrelated releases, so severity thresholds and a triage owner must be agreed before turning it on.

SEC-22

Document and rehearse incident response and breach notification

Proposed
Ubio adminMust
As a Ubio admin, I want a written incident procedure we have actually practised, so that the first time we work out who calls the customer is not during a live breach.
Acceptance criteria
  1. Given an incident is declared, when the runbook is followed, then it defines severity levels, an incident commander, a communications owner and the escalation path, with named people per role.
  2. Given a suspected data exposure, when the assessment completes, then there is a documented decision on notification with the GDPR 72-hour clock tracked from the point of awareness.
  3. Given customers are affected, when notification goes out, then it uses a maintained contact list per organisation rather than whichever email happens to be on the account.
  4. Given an incident closes, when the post-incident review runs, then actions are recorded with owners and dates and are visible in the next review.
  5. Given no incident has occurred in six months, when the drill schedule fires, then a tabletop exercise runs and its outcome is recorded as evidence.
Implementation notes

Should tie into the status page proposed in PERF-22 so that customer communication has a single canonical channel, and into SEC-15's audit log for reconstructing what happened.

Edge cases & risks

The likeliest first incident is credential-based — a leaked non-expiring PAT — and with no lastUsedAt stamp and no audit log we currently cannot answer "what did they access". That evidence gap is the real blocker to a credible notification.

PERF 6 built5 partial11 proposed

Performance, scale & reliability

The platform already carries some genuinely careful engineering: a hand-specified Atlas Search index over a million-plus hotels, cursor pagination that never pays the deep-skip cost, an export pipeline that streams gigabytes through constant memory, and a scraper fleet designed for fifty concurrent fetches in batches of a hundred. What it does not have is anything that measures whether those pieces are meeting a target, or that degrades gracefully when an upstream does not. This epic covers what makes the system fast today, the handful of paths that will not survive real volume, and the reliability commitments a paying OTA will expect in a contract.

PERF-01

Search a million hotels through a purpose-built index

Built
OTA product managerMust
As an OTA product manager, I want filtered search across the whole catalogue to come back fast enough to feel interactive, so that my team can iterate on a supply query instead of waiting on each attempt.
Acceptance criteria
  1. Given a query with name, country, city, stars and feature filters, when GET /search runs, then it executes as a single Atlas $search stage against atlas_custom_search_10 on canonicalData6.
  2. Given the index is dynamic: false, when a new field is added to the schema, then it is not indexed until it is added explicitly, keeping index size and build time bounded.
  3. Given scoring versus filtering clauses, when the pipeline is built, then name text, geoWithin.circle on _loc, fuzzy feature text (maxEdits 2) and meta-boolean equality go in compound.must, while starRating in, countryCode in, city text and refs.<type> equality go in the non-scoring compound.filter.
  4. Given hotelIdType === 'huId', when the query is built, then it short-circuits to $match {_id} and skips the search stage entirely.
  5. Given a request arrives with no filter clauses at all, when the pipeline is built, then it becomes $match {_id: 'impossible-id'} and returns nothing rather than scanning the collection.
Implementation notes

Index fields are _loc, info.name, info.starRating, location.countryCode (token, lowercase), location.city, features.title, rating.numberOfReviews, rating.overallRating.max, meta..available and refs. (token), analyser lucene.standard; results sort on rating.numberOfReviews: -1.

Edge cases & risks

The R&D document states a sub-second search target but nothing measures it, so a regression from a new clause type would be invisible until a customer complains. Fuzzy feature matching at maxEdits 2 is the most expensive clause and scales with the number of feature filters a user stacks.

PERF-02

Page through results with a cursor rather than an offset

Built
Customer data engineerMust
As a Customer data engineer, I want page 500 of a result set to cost the same as page 2, so that walking a large query from my ingestion job does not slow down as it goes.
Acceptance criteria
  1. Given a first page request, when the search runs, then 11 documents are fetched, 10 are returned, and the presence of the 11th determines whether a nextPageToken is issued.
  2. Given I pass pageToken on the next request, when the pipeline runs, then it uses Atlas searchAfter with the stored searchSequenceToken rather than a $skip.
  3. Given I reach the last page, when fewer than 11 documents come back, then nextPageToken is absent and the client stops.
  4. Given I pass a malformed or expired pageToken, when the request is handled, then it fails with a clear 400 rather than silently returning page one.
  5. Given the portal results table, when the IntersectionObserver sentinel enters the viewport, then loadMoreHotels() appends the next page and no page numbers are rendered.
Implementation notes

GET /search returns {total, nextPageToken, hotels: HotelPreview[]} at page size 10; the portal side is HotelManager.loadMoreHotels() feeding HotelsTable.vueHotelResultsTable.vue.

Edge cases & risks

Cursor pagination cannot express "jump to page N" or re-sort mid-walk, and the sort key rating.numberOfReviews is not unique, so ties depend on the sequence token remaining stable. Documents updated between pages can be skipped or repeated during a long walk.

PERF-03

Show a result count that does not cost a full scan

Built
Data analystShould
As a Data analyst, I want the hotel count on a filtered search to appear immediately, so that I can judge whether my filter is useful before I start paging.
Acceptance criteria
  1. Given a filtered search, when the count is computed, then it uses a $searchMeta total with a threshold of 1000 rather than counting every matching document.
  2. Given the match set exceeds the threshold, when the count is returned, then it is reported as a bounded value and the portal filter bar renders "<total> hotels · refreshed just now" accordingly.
  3. Given the query has no search clauses, when a count is needed, then it falls back to estimatedDocumentCount instead of a collection count.
  4. Given the nearby-hotel count on the map picker, when it exceeds the cap, then the UI shows "1000+" rather than a precise figure.
  5. Given the count query fails, when the response is assembled, then the results still render and only the count is degraded.
Implementation notes

CanonicalDataRepo.count drives both GET /search and the debounced (500 ms) live count in components/search/LocationPickerModal.vue via getNearbyHotels.

Edge cases & risks

A thresholded count is an approximation presented as a number, which quietly misleads anyone reconciling it against an export row count. The virtual-selection logic in the portal branches on totalResults > 1000, so an approximate total decides which selection mode a user gets.

PERF-04

Export a very large list in constant memory

Built
Customer data engineerMust
As a Customer data engineer, I want to export a list of hundreds of thousands of hotels without the service falling over, so that my nightly refresh is a single file rather than a paging loop.
Acceptance criteria
  1. Given an export is requested, when it runs, then the pipeline is Readable.from(dbStream) → transform → toJsonArray → gzip → GCS uploadStream and process memory stays flat regardless of list size.
  2. Given the export type is full, preview or reviews, when records are serialised, then they use HotelProfileSchema, HotelPreviewSchema or one HotelReviewExport per review respectively.
  3. Given the upload completes, when the record is finalised, then gcsFilename, gcsFileSize, status and expiresAt (now + 30 days) are written to the exports document.
  4. Given I request the download, when the export is ready, then GET /lists/{listId}/exports/{exportId}/download returns 200 with {downloadUrl, filename, expiresAt, fileSize} and the signed URL is valid for 15 minutes.
  5. Given the export is still running, when I poll the download route, then it returns 202; given it has expired, then it returns 410 rather than a broken URL.
Implementation notes

ExportService with GoogleCloudStorageService.uploadStream (application/gzip, no-cache, non-resumable) and a v4 signed URL; object key is {listId}/{exportId}/{type}-{timestamp}.json.gz; the portal polls through ListExportsManager.

Edge cases & risks

The whole export runs inside the API process as a fire-and-forget promise, so a deploy mid-export loses it with no resume (see PERF-16). A 15-minute signed URL is short enough that a user who walks away from the exports drawer must request a new one.

PERF-05

Ingest source data in streamed, buffered bulk writes

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want the import to stream and batch rather than load collections into memory, so that a full reload of Booking or Google data completes without exhausting the box.
Acceptance criteria
  1. Given an import run, when a source collection is read, then it is streamed in chunks (default 100,000) sorted by _id with a per-schema projection rather than read whole.
  2. Given image records arrive, when they are written, then ImageUpdateService buffers up to 100 documents or 5 seconds before flushing a bulk write.
  3. Given the run is interrupted, when it restarts, then the _id-ordered chunking allows it to resume from a known point rather than from zero.
  4. Given a batch contains an invalid document, when the write executes, then the invalid record is reported and the rest of the batch still lands.
  5. Given progress is being made, when the CLI runs, then a progress bar reflects real throughput so an operator can tell a slow run from a stalled one.
Implementation notes

packages/import with AbstractDataSource / AbstractReviewsDataSource / AbstractImagesDataSource reading a secondary-preferred Mongo (MONGO_URL_IMPORT); merge is HotelDataUpdateService.combineData walking FIELD_GROUPS in DS_NAMES priority order.

Edge cases & risks

Batch sizes and skips are hard-coded in app.ts, so tuning requires a code change and a release. ImageUpdateService carries a TODO to deduplicate images, and the canonical path drops width, height and caption that the image path preserves — a data-quality divergence that grows with every run.

PERF-06

Keep the scraper fleet saturated without hammering sources

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want scraping to run continuously at a controlled concurrency, so that freshness improves without getting our proxies banned.
Acceptance criteria
  1. Given the scraping service is running, when a task cycle begins, then it selects a batch of at most 100 stale hotels and processes them at concurrency 50.
  2. Given a hotel's status is null or success older than the 30-day stale threshold and the required ref exists, when work is selected, then it is included; otherwise it is skipped.
  3. Given a task cycle finishes, when the runner waits, then it sleeps for SCRAPING_INTERVAL (default 60 s) using an interruptible Sleepbox so shutdown is immediate.
  4. Given a scrape fails, when the lifecycle completes, then scrapingStatuses records error with the message and updatedAt, and the hotel is not retried in the same cycle.
  5. Given RUN_TASKS_ON_START is false, when the service boots, then no scraping begins until the first scheduled tick.
Implementation notes

AbstractScrapingService (batchLimit = 100, concurrency = 50, staleTimeThresholdMs = 30 days) driven by PeriodicTaskRunner.schedule(task, interval, name) across GoogleHotels, BookingHotels, BookingRooms and TripAdvisorHotels; lifecycle is scraping → fetch → checkData → transformData → HotelDataUpdateService.execute → success|error.

Edge cases & risks

There is a TODO noting that re-scraping errors should be a separate method, so errored hotels can be starved indefinitely behind the stale-success queue. Concurrency is a fixed constant with no adaptive back-off, so a source that starts rate-limiting us gets the same 50 parallel requests until someone redeploys.

PERF-07

Rate-limit callers per principal

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want every expensive endpoint protected by a per-principal limit, so that one runaway integration cannot degrade the service for everyone else.
Acceptance criteria
  1. Given REDIS_URL is configured, when limits are counted, then rate-limiter-flexible uses Redis so the limit holds across all instances rather than per process.
  2. Given a principal exceeds RATE_LIMIT_REQUESTS (default 100) within RATE_LIMIT_DURATION_SECONDS (default 60), when the next request arrives, then it returns 429 with a Retry-After header.
  3. Given the limiter is currently applied only to GET /metasearch, when this story completes, then GET /search, POST /lists/upload, POST /lists/{listId}/exports and the AI assistant routes are covered as well, with per-route budgets.
  4. Given Redis is unavailable, when the limiter falls back to in-memory counting, then the degradation is logged rather than silently allowing unlimited traffic per instance.
  5. Given a limit is applied, when the caller inspects the response, then the remaining budget and reset time are exposed in headers so a client can pace itself.
Implementation notes

RateLimitService is unit-tested and keyed by principal; the metasearch route is the only registration point today, and it proxies to METASEARCH_SERVICE_URL where responses take 2–5 seconds.

Edge cases & risks

GET /search is the most expensive uncovered route — a scripted crawl of a broad filter set can exhaust Atlas Search capacity for every tenant. Keying by principal means a single org with many PATs still gets N times the budget, which is why PERF-18's per-org fair queueing matters.

PERF-08

Build lists in the background without blocking the request

Partial
OTA supply managerMust
As an OTA supply manager, I want a large CSV upload to return immediately and process in the background, so that my browser is not sitting on an open request while 50,000 rows are matched.
Acceptance criteria
  1. Given I upload a CSV to POST /lists/upload, when the request returns, then the list exists with status created and processing continues asynchronously.
  2. Given processing is under way, when the portal polls, then ListPollingManager refreshes details, stats, entries and review status every 2.5 s while status is processing or created, and stops when it reaches ready or error.
  3. Given the service is asked to shut down, when background work is in flight, then PendingPromises drains gracefully rather than dropping the work mid-write.
  4. Given the list finishes, when access tracking records lists:create, then it must not require a poll loop that runs every second for up to ten minutes — the current listCreation implementation blocks a slot for the whole window, which is the named gap.
  5. Given the list errors, when the portal polls, then the error string is surfaced on the list row and a List Processing Failed toast is shown.
Implementation notes

PendingPromises is a fire-and-forget list with a graceful drain used for list creation and exports; AccessTracking polls list status every 1 s up to 10 minutes before writing the lists:create log row.

Edge cases & risks

Fire-and-forget work does not survive a restart, so a deploy during a large import leaves a list stuck in processing forever with no reaper. The ten-minute poll multiplies with concurrent uploads and is pure wasted work in the common case where the list is ready in seconds.

PERF-09

Keep the portal responsive on very large result sets

Partial
OTA supply managerShould
As an OTA supply manager, I want the search results screen to stay usable when my filter matches tens of thousands of hotels, so that selecting and saving a big set does not lock up the tab.
Acceptance criteria
  1. Given results are loading, when the table renders, then a skeleton is shown rather than stale content from the previous query, per the portal's own loading-state rule.
  2. Given I scroll to the bottom, when the sentinel intersects, then the next cursor page is appended without a full re-render.
  3. Given a summary is generating, when GeneratingSummaryPoller polls at 3 s, then it gives up after MAX_POLLS = 20 and renders an error card rather than spinning forever.
  4. Given I choose select-all-across-pages, when the bulk fetch runs, then it currently walks every page client-side through forEachHotelsPageForCurrentFilters with a Stop button — this must become a server-side "select by filter" operation so the browser is not fetching thousands of pages.
  5. Given the result total exceeds STATIC_SELECTION_LIMIT (1000), when I select all, then virtual selection engages, the preview is capped at MAX_VIRTUAL_PREVIEW_HOTELS = 250, and a toast explains the cap rather than silently truncating.
Implementation notes

HotelSelectionManager (selectAllVirtual, syncVisibleHotels, hydrate) persists ids to sessionStorage hotel-universe:selected-hotels; utils/selection.ts holds the limits and createVirtualSelectionSaveToast.

Edge cases & risks

A client-side select-all across a 100,000-hotel filter is thousands of sequential API calls, each rate-limit-eligible, with an abort that leaves partial state. Persisting every selected id into sessionStorage will hit browser storage quotas well before the virtual-selection threshold is reached on some browsers.

PERF-10

Serve resized images with cacheable headers

Partial
DeveloperShould
As a Developer, I want hotel thumbnails to be cached by the browser and any CDN in front of us, so that a gallery-heavy page does not re-resize the same image on every load.
Acceptance criteria
  1. Given GET /img/resize?hash&w&h, when the image is produced, then it is a sharp cover-resize with cubic interpolation at JPEG quality 75.
  2. Given the response is returned, when headers are inspected, then a long-lived Cache-Control is present — today the header is commented out, which is the named gap.
  3. Given the same hash, width and height are requested again, when a cache is in front of the service, then the request is served without touching sharp.
  4. Given an unknown or malformed hash, when the request runs, then it fails fast with a 400 and no CPU is spent on decode.
  5. Given a very large requested width, when the parameters are validated, then they are clamped to a supported set rather than allowing arbitrary resize dimensions.
Implementation notes

The img package exposes exactly one route; URLs are minted during ingest by encodeImageUrls as https://images.automation.cloud/img?hash=<AES-256-CBC ciphertext>.<iv>.

Edge cases & risks

Because the resize output is deterministic per hash and size, the missing cache header is pure waste — every hotel detail page and every gallery re-does the work. Unbounded width and height parameters turn the resizer into a cheap CPU-exhaustion target, compounded by the missing auth in SEC-08.

PERF-11

Find the organisation for a Stripe webhook without scanning

Partial
Ubio finance opsMust
As a Ubio finance ops, I want subscription webhooks to resolve the right organisation in constant time, so that billing events keep working as the customer base grows.
Acceptance criteria
  1. Given a webhook arrives carrying a Stripe customer id, when the organisation is resolved, then it uses an indexed lookup on billing.stripeCustomerId rather than findAll().find(...) over the whole organizations collection.
  2. Given the index does not yet exist, when the migration runs, then it is created and the change is verified against production cardinality.
  3. Given no organisation matches the customer id, when resolution fails, then the failure is logged with the event id and surfaces as an alert rather than being ignored.
  4. Given checkout.session.completed carries metadata.organizationId, when the handler runs, then it prefers the metadata over any lookup at all.
  5. Given the collection grows to tens of thousands of organisations, when a burst of webhooks arrives, then handler latency stays flat.
Implementation notes

BillingService currently loads all organisations and filters in memory for each webhook; the checkout flow already sets metadata: { organizationId } on the session.

Edge cases & risks

The scan is invisible today because the collection is small, which is exactly why it will be discovered during a growth spurt with a webhook backlog. Combined with SEC-11's swallowed errors, a timeout here produces a silent tier mismatch nobody notices.

PERF-12

Set and publish latency objectives per endpoint

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want p95 and p99 targets defined per endpoint and measured continuously, so that "the search feels slow" becomes a number we can act on.
Acceptance criteria
  1. Given the SLO set is defined, when it is published internally, then each endpoint class has a p95 and p99 target — for example sub-second p95 on GET /search as the R&D document already claims, and a separate looser target for GET /metasearch which is documented at 2–5 s.
  2. Given traffic is flowing, when the dashboard renders, then it shows per-endpoint p50/p95/p99, error rate and throughput broken down by tier.
  3. Given an endpoint breaches its p99 target for a sustained window, when the alert rule fires, then it routes to an owner with the endpoint and the offending query shape.
  4. Given a release ships, when the following hour is compared to the preceding one, then a latency regression is attributable to the deploy.
  5. Given an endpoint has no defined SLO, when it is added to the router, then the CI check flags it rather than allowing an unmeasured route into production.
Implementation notes

Builds on the @ubio/framework structured logging already in place; needs request timing emitted per route and an aggregation target. GET /metasearch should be measured excluding upstream time so our overhead is separable from the proxied service's.

Edge cases & risks

Search latency depends heavily on filter shape — a fuzzy multi-feature query is a different workload from a huId short-circuit — so a single aggregate p95 will hide the slow cases unless it is segmented by query shape.

PERF-13

Load-test the critical paths before they are contractually promised

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want a repeatable load test against a production-shaped dataset, so that we know where the system breaks before a customer finds out.
Acceptance criteria
  1. Given the test suite runs, when it executes, then it covers concurrent GET /search at realistic filter mixes, a 100,000-row POST /lists/upload, concurrent full exports and a GET /metasearch burst.
  2. Given a run completes, when the report is produced, then it states the throughput at which each endpoint breaches its PERF-12 target and what resource saturated first.
  3. Given a run is repeated after a change, when results are compared, then the delta is reported so capacity regressions are visible.
  4. Given the test dataset is used, when it is generated, then it contains no real customer data.
  5. Given a run saturates a shared dependency such as Atlas Search or the metasearch service, when that happens, then the run aborts rather than degrading a shared environment.
Implementation notes

Should reuse the existing integration-test fixtures for list upload and export so the load scenarios exercise the same code paths as CI.

Edge cases & risks

Load-testing GET /metasearch means load-testing someone else's service through our proxy, so it needs an agreed window or a stub. Atlas Search performance is cluster-tier dependent, so results from a smaller test cluster will not extrapolate.

PERF-14

Cache hot query results and serve images from a CDN

Proposed
OTA product managerShould
As an OTA product manager, I want repeated identical searches and hotel-detail loads to be served from cache, so that my team's shared dashboards do not re-run the same expensive query all day.
Acceptance criteria
  1. Given the same filter set and page token, when a second request arrives within the cache window, then it is served from cache and the response indicates the cache age.
  2. Given a hotel profile is fetched repeatedly, when GET /hotels/{huId} is served, then the canonical document is cached with an invalidation hook on updatedAt.
  3. Given canonicalData6 is updated for a hotel by the merge, when the change lands, then cached entries for that huId are invalidated within a stated bound.
  4. Given an image URL is requested, when it is served, then it comes from a CDN edge with the PERF-10 cache header and only cache misses reach the img service.
  5. Given a request carries user-specific state such as rich-content subscription status, when it is cached, then the cache key includes the org so no cross-tenant response is ever served.
Implementation notes

Redis is already a dependency when REDIS_URL is set (for rate limiting), so it is the natural cache store; image caching is a DNS and CDN change in front of images.automation.cloud.

Edge cases & risks

Caching org-scoped responses is where cross-tenant leaks come from — the key must include orgId and tier, not just the query. Search results embed availability flags and ratings that change with every ingest, so a long TTL trades freshness for speed in a way customers will notice.

PERF-15

Serve read traffic from replicas

Proposed
Ubio platform engineerCould
As a Ubio platform engineer, I want heavy read workloads pointed at replicas, so that a large export or a bulk import does not slow down interactive search for everyone.
Acceptance criteria
  1. Given an export stream is running, when it reads canonicalData6, then it uses a secondary read preference so primary capacity is preserved for interactive traffic.
  2. Given a read is served from a replica, when the data may be stale, then the staleness bound is documented and acceptable for that operation.
  3. Given a write-then-read sequence such as creating a list and immediately loading it, when the read runs, then it is routed to the primary so the user never sees their own write missing.
  4. Given replica lag exceeds a threshold, when the monitor fires, then reads fall back to the primary and an alert is raised.
  5. Given the import CLI runs, when it reads the source database, then it continues to use the existing secondaryPreferred setting.
Implementation notes

MONGO_URL_IMPORT already uses secondaryPreferred, so the pattern exists; this extends it to the HU database for ExportService streams, CanonicalDataRepo.stream and the /stats aggregation.

Edge cases & risks

Read-your-own-writes is the trap: list creation, entry approval and export status all have immediate follow-up reads in the portal that will break subtly under replica lag. Atlas Search queries have their own node topology and do not benefit the same way.

PERF-16

Move exports and bulk operations onto a durable job queue

Proposed
Customer data engineerMust
As a Customer data engineer, I want my export to survive a deploy, so that a two-hour export of a large list does not vanish because someone shipped a patch.
Acceptance criteria
  1. Given an export, list build or bulk direct-pricing enable is requested, when it is accepted, then a durable job is enqueued and the request returns a job id immediately.
  2. Given the API process restarts mid-job, when it comes back, then the job is picked up again and completes, rather than being lost as it is today with PendingPromises.
  3. Given a job fails transiently, when it is retried, then retries are bounded with backoff and the final failure is recorded on the exports or lists record with a reason.
  4. Given a job is stuck beyond its expected duration, when the reaper runs, then it is marked failed so the portal stops polling a list that will never become ready.
  5. Given a customer enqueues many jobs at once, when workers pick work, then per-org concurrency is capped so one tenant cannot occupy the whole pool.
Implementation notes

Replaces the fire-and-forget PendingPromises pattern for ExportService and list creation; workers can run in the existing scraping-service deployment shape, which already runs long-lived tasks under PeriodicTaskRunner.

Edge cases & risks

The export pipeline streams straight to GCS, so a retry must handle partially uploaded objects and not leave orphaned files consuming the 30-day lifecycle. Job ids become a new customer-visible concept that needs to appear in the exports drawer.

PERF-17

Apply backpressure and fair queueing per organisation

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want work scheduled fairly across tenants, so that one customer's bulk job cannot starve everyone else's interactive requests.
Acceptance criteria
  1. Given multiple organisations have queued work, when workers pull jobs, then scheduling is round-robin across orgs rather than first-in-first-out globally.
  2. Given an org exceeds its concurrent-job allowance, when it enqueues more, then the extra jobs queue behind its own work and the response tells the caller their queue position.
  3. Given the system is near capacity, when a new expensive request arrives, then it is shed with 429 and Retry-After rather than accepted and timed out.
  4. Given a tier carries a higher allowance, when jobs are scheduled, then FULL-tier work is given a larger share than TRIAL.
  5. Given shedding occurs, when it happens, then it is recorded per org so we can tell a capacity problem from a customer problem.
Implementation notes

Builds directly on PERF-16's queue and on the existing tier model (AccessTierService, trial | paid | full) which already differentiates entitlements.

Edge cases & risks

Fair queueing across orgs is meaningless if a single org holds many PATs and the rate limiter keys per principal — the two need to share an org dimension. Shedding requests that a customer has already paid for needs a clear commercial policy or it becomes a support argument.

PERF-18

Degrade gracefully when an upstream is slow or down

Proposed
Revenue managerMust
As a Revenue manager, I want the price screens to tell me what is unavailable rather than hanging, so that I can act on the data that did arrive instead of staring at spinners.
Acceptance criteria
  1. Given the metasearch service is responding slowly, when GET /metasearch exceeds its timeout, then the request fails fast with a specific error and the portal renders the metasearch error Message rather than an indefinite skeleton.
  2. Given repeated upstream failures cross a threshold, when the circuit breaker opens, then further calls fail immediately for a cool-down period and the breaker state is visible in monitoring.
  3. Given the breaker is open, when a customer opens the price matrix or price insights, then a banner explains that live pricing is temporarily unavailable and cached or snapshot data is shown where it exists.
  4. Given a scraper source such as Booking or TripAdvisor starts erroring, when the breaker for that source opens, then that source is skipped for the cool-down while the other sources continue, and the skip is recorded in scrapingStatuses.
  5. Given the breaker closes again, when traffic resumes, then it ramps rather than sending the full backlog at once.
Implementation notes

Applies to the METASEARCH_SERVICE_URL proxy, the Google Places Text Search used by packages/link, the NS_ADDRESS_SPLITTING_ENDPOINT, and the three scraper remotes; the portal already has error and empty states ready to receive a clean failure.

Edge cases & risks

A breaker that opens on a slow-but-working metasearch service turns a degraded experience into no experience, so thresholds need to account for the documented 2–5 second normal response. Failing fast on metasearch must not consume a customer's prepaid metasearch allowance.

PERF-19

Review indexes and alert on slow queries

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want slow queries surfaced automatically and the index set reviewed against real traffic, so that the next full collection scan is found by a monitor rather than by an outage.
Acceptance criteria
  1. Given the profiler is enabled, when a query exceeds a latency threshold, then it is captured with its shape, collection and execution plan.
  2. Given a captured query performs a collection scan, when the alert fires, then it names the collection and the calling route.
  3. Given the index review runs, when it completes, then every index is justified by observed traffic and unused indexes are proposed for removal.
  4. Given a new query shape appears in production, when it has no supporting index, then it is flagged within a day rather than at the next incident.
  5. Given the Stripe webhook lookup in PERF-11 is unfixed, when the monitor runs, then it is reported as an existing scan rather than treated as a new finding.
Implementation notes

Current indexes are _loc 2dsphere, each refs.*, rating.numberOfReviews: -1 and the Atlas Search index on canonicalData6, plus unique {dsName, dsHotelId} on sourceHotelData5; the org-scoped collections (lists, listEntries, exports, accessTokens) need their own review.

Edge cases & risks

listEntries is queried by listId at high volume during list building and review; an index gap there degrades exactly when a customer is watching their upload process. Every added index costs write throughput on the ingest path, which is already the heaviest writer.

PERF-20

Scale horizontally with an explicit autoscaling policy

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want the API to scale out on load and back in when quiet, so that a spike does not require someone to notice and act.
Acceptance criteria
  1. Given the API is stateless per request, when instances are added, then rate limiting stays correct because counters live in Redis, and session state stays correct because tokens are self-contained JWTs.
  2. Given CPU or request latency crosses a threshold, when the policy evaluates, then instances scale out within a stated time and scale in after a cool-down.
  3. Given an instance is terminated during scale-in, when it shuts down, then in-flight requests drain and PendingPromises/queue workers finish or hand back their work.
  4. Given the scraping service is scaled, when multiple instances run, then work selection does not hand the same hotel to two instances simultaneously.
  5. Given a scaling event occurs, when it is recorded, then it is correlated with the latency dashboard so we can tell whether it helped.
Implementation notes

The API is already containerised (Node 22 alpine, port 8080) and deployed via the dispatched workflow to ubio/infrastructure; the main blocker is the scraping service's getStaleHotels selection, which assumes a single runner.

Edge cases & risks

Two scraper instances selecting the same stale batch would double our request volume against Booking and TripAdvisor — precisely the traffic pattern that gets an IP range banned. Scale-in during a long export loses the work until PERF-16 lands.

PERF-21

Prove we can restore from backup within an agreed window

Proposed
Organisation adminMust
As an Organisation admin, I want a stated recovery point and recovery time backed by rehearsed restores, so that I can put Hotel Universe on a critical path in my own operation.
Acceptance criteria
  1. Given the DR plan is defined, when it is published, then it states an RPO and RTO per data class — org-owned data (lists, entries, exports metadata), the canonical catalogue, and the source/scraping data.
  2. Given a restore drill runs on schedule, when it completes, then the measured recovery time is recorded and compared against the RTO, and a miss creates an action.
  3. Given a restore is performed, when it finishes, then referential consistency between lists, listEntries and exports is verified rather than assumed.
  4. Given the GCS export bucket is in scope, when the plan is written, then it accounts for the existing 7-day soft delete and 30-day lifecycle deletion.
  5. Given a drill fails, when the result is recorded, then the published RTO is corrected rather than left aspirational.
Implementation notes

Relies on MongoDB Atlas backup and point-in-time restore plus GCS object versioning; the npm run verify:gcs script gives a starting point for verifying bucket access after a restore.

Edge cases & risks

The canonical catalogue can in principle be rebuilt from sourceHotelData5 and the import pipeline, but that path is measured in days, not hours, and the huId resolution ladder would need to produce identical ids — worth testing before relying on it.

PERF-22

Publish a status page fed by real health checks

Proposed
DeveloperShould
As a Developer, I want a status page that reflects actual system health, so that when my integration starts failing I can tell whether it is me or you.
Acceptance criteria
  1. Given the status page is live, when it renders, then it shows per-component status for the API, search, metasearch, direct pricing, exports and the MCP server, each driven by an automated check.
  2. Given a component check fails repeatedly, when the threshold is crossed, then the component is marked degraded or down automatically without a human posting.
  3. Given an incident is open, when updates are posted, then they appear on the status page and are the same text used in customer emails.
  4. Given the landing footer currently renders a static "Status: all systems green" string, when this ships, then that string is driven by the real feed or removed.
  5. Given a customer subscribes to updates, when a component changes state, then they are notified.
Implementation notes

GET /status and the portal's api.healthCheck() exist as trivial liveness signals; real checks need to exercise a search, an export enqueue and a metasearch proxy call, feeding the same page linked from LandingFooter.

Edge cases & risks

A hard-coded "all systems green" during a live outage is actively damaging to trust, so it should be removed even before the real page exists. Synthetic checks that hit GET /metasearch consume real upstream capacity and possibly real cost, so they need a dedicated test principal.

QUAL 5 built6 partial11 proposed

Testing, observability & release quality

There is a real testing habit here — integration suites across the main API surfaces, unit tests around the genuinely tricky logic like huId generation and canonical merge, portal vitest coverage of the formatters and the summary poller — plus lint gates, tagged image builds and an automated deploy dispatch. The weak spot is everything that describes the system to someone outside it: the OpenAPI spec is six minor versions behind, the HTTP examples document an endpoint that does not exist, both READMEs describe repositories that have since moved, and one shipped route serves mock data. This epic covers the quality machinery that works, the documentation drift that is actively misleading customers, and the testing and observability layers needed before the platform can promise anything.

QUAL-01

Cover the API surface with integration tests

Partial
Ubio platform engineerMust
As a Ubio platform engineer, I want each API area exercised end to end against a real database, so that a refactor that breaks org scoping or list processing fails in CI rather than in production.
Acceptance criteria
  1. Given the integration suite runs, when it completes, then it covers auth, users, organization, invitation, tokens, lists, search, export, metasearch and sources.
  2. Given a test asserts a permission boundary, when it runs, then it verifies both the allowed case and the 403 case — for example GET /lists/{listId} across organisations.
  3. Given a test needs a database, when it starts, then it provisions and tears down isolated state so runs are repeatable and order-independent.
  4. Given a suite fails, when CI reports, then the failing endpoint and assertion are identifiable without re-running locally.
  5. Given billing, scrapers, stats and the img service currently have no integration coverage, when this story completes, then each has at least a happy-path and an auth-failure test — this coverage gap is the named partial.
Implementation notes

Suites live alongside the api package; the sources suite exists despite POST /sources having no auth check, which is exactly the kind of gap a permission-boundary assertion would have caught.

Edge cases & risks

Testing GET /metasearch requires either a live METASEARCH_SERVICE_URL or a stub; if the suite currently depends on the real service it is a flaky external dependency in CI. Billing tests need Stripe fixtures or the webhook path stays untested precisely where SEC-11's swallowed errors live.

QUAL-02

Unit-test the logic that is expensive to get wrong

Built
Ubio data ops engineerMust
As a Ubio data ops engineer, I want the deterministic pieces of ingest and access control covered by fast unit tests, so that a change to the merge or the id generator cannot silently corrupt the catalogue.
Acceptance criteria
  1. Given the unit suite runs, when it completes, then it covers RateLimitService, AccessTierService, JsonSchemaBuilder, OpenApiSpecBuilder, HuId, CanonicalDataRepo, HotelDataUpdateService, BookingScraper and GoogleTravelHtmlParser.
  2. Given HuId is tested, when a known dsHotelId is passed, then the generated id matches a fixed expected value, locking the sha1 derivation against accidental change.
  3. Given HotelDataUpdateService is tested, when a lower-priority source is merged over a field already claimed by a higher-priority source, then the test asserts the existing value survives and _dsNames is unchanged.
  4. Given AccessTierService is tested, when a TRIAL principal requests metasearch or reviews, then the test asserts the refusal path as well as the allowed FULL path.
  5. Given any of these tests fails, when a commit is pushed, then the build is red before review.
Implementation notes

HuId is the highest-stakes of these: every customer-held Hotel Universe id derives from sha1('31b54e6e4b#' + dsHotelId).slice(0,10), so the test is effectively a compatibility contract.

Edge cases & risks

GoogleTravelHtmlParser and BookingScraper are tested against captured fixtures, which go stale the moment the source site changes its markup — a green test says nothing about whether scraping still works today. That is what QUAL-14's golden-file regime is for.

QUAL-03

Test the portal's pure logic

Partial
Ubio platform engineerShould
As a Ubio platform engineer, I want the portal's conversion and polling utilities under test, so that a formatting or access-mode change does not quietly break every hotel card.
Acceptance criteria
  1. Given the vitest suite runs, when it completes, then formatters.test.ts, hotel.test.ts, hotelPreviewToHotel.test.ts and GeneratingSummaryPoller.test.ts all pass.
  2. Given GeneratingSummaryPoller is tested, when the poller exceeds MAX_POLLS = 20, then the test asserts it transitions to the error state rather than polling indefinitely.
  3. Given hotelPreviewToHotel is tested, when a HotelPreview is converted for a preview-access profile, then the shape the detail view expects is asserted field by field.
  4. Given the manager layer (HotelManager, HotelSelectionManager, ListPollingManager, AiAssistantManager) has no tests today, when this story completes, then each has coverage of its state transitions — this is the named gap.
  5. Given a manager test fails, when CI runs, then npm test in the portal package fails the build.
Implementation notes

Portal is Vue 3 Options API with mesh-ioc managers, so the managers are plain classes and are directly unit-testable without mounting components; utils/selection.ts and utils/billingFormatters.ts are similarly pure.

Edge cases & risks

HotelSelectionManager carries the virtual-selection thresholds and sessionStorage persistence, which is the most defect-prone untested logic in the portal. AiAssistantManager persists with STORAGE_VERSION = 10, implying nine prior migrations shipped without tests.

QUAL-04

Gate every commit and release on lint

Built
Ubio platform engineerShould
As a Ubio platform engineer, I want style and correctness rules enforced automatically, so that review time goes on behaviour rather than formatting.
Acceptance criteria
  1. Given I commit, when the pre-commit hook runs, then lint executes over the staged files and blocks the commit on error.
  2. Given I run npm version, when preversion fires, then the lint gate runs and a failure aborts the version bump before a tag is created.
  3. Given the portal is built, when vue-tsc -b && vite build runs, then type errors fail the build.
  4. Given a rule is violated in a way the tool can fix, when lint runs with the fix flag, then it is corrected rather than reported.
  5. Given lint passes but CI still fails, when the logs are read, then the failure is attributable to tests rather than style.
Implementation notes

eslint plus biome across the monorepo with pre-commit hooks and a preversion gate; the portal adds vue-tsc type-checking to its build script.

Edge cases & risks

Because tagging is what triggers the production image build, a preversion gate is the last automated check before deploy — if it is ever skipped with --no-verify there is nothing behind it.

QUAL-05

Build and deploy from a tag automatically

Built
Ubio platform engineerMust
As a Ubio platform engineer, I want a tag push to produce the production images and trigger the deploy, so that releases are reproducible and nobody builds from a laptop.
Acceptance criteria
  1. Given a tag is pushed, when GitHub Actions runs, then it builds both hotel-universe-api and hotel-universe-scraping-service images to eu.gcr.io/automation-cloud-registry.
  2. Given the images build successfully, when the workflow continues, then it dispatches a production deploy to ubio/infrastructure.
  3. Given the image build fails, when the workflow ends, then no deploy is dispatched and the failure is visible on the tag.
  4. Given a deploy completes, when the portal profile menu is opened, then the footer shows the new API: v{status.version} from GET /status.
  5. Given a deploy needs to be reverted, when the previous tag is redeployed, then the same image digest is used rather than a rebuild.
Implementation notes

Node 22 alpine images running as uid 1001 on port 8080; the portal reports Portal: v{__PORTAL_VERSION__} alongside the API version in ProfileMenu.vue.

Edge cases & risks

There is no staging gate between tag and production and no automated smoke test after deploy, so the first signal that a release is bad is a customer or an alert. Two images deploy independently, so an API and scraper version skew is possible mid-rollout.

QUAL-06

Keep the written conventions that humans and agents both follow

Built
Ubio platform engineerShould
As a Ubio platform engineer, I want the codebase conventions written down where the AI tooling reads them, so that generated code matches the house style instead of drifting.
Acceptance criteria
  1. Given a contributor opens the repo, when they look for conventions, then .cursor/rules (including git.mdc) states them and they are current.
  2. Given the portal is being changed, when a developer consults the README conventions, then they find the rules that actually govern review: Options API preferred, .ts for managers, avoid any, mesh-ioc for state, Tailwind-first, PrimeVue 4.
  3. Given a new screen is designed, when the layout rule is applied, then top bar is main sections, left sidebar is second level, tabs are third level, and URLs address resources.
  4. Given a loading state is implemented, when it renders, then it follows "no stale content, show skeleton".
  5. Given a dangerous action is added, when it is placed, then it sits below the content in red with a confirmation, per the documented action-placement rule.
Implementation notes

Conventions live in .cursor/rules on the API side and in the portal README, which also carries capitalization rules ("Upload list" not "Upload List") and links to the Ubio brand guidelines.

Edge cases & risks

These rules are only discoverable if the file they live in is trustworthy, and the portal README that holds them also declares the repo deprecated — see QUAL-08. Conventions with no lint rule behind them are enforced only at review time.

QUAL-07

Publish an OpenAPI spec that matches the running API

Partial
DeveloperMust
As a Developer, I want the published spec to describe the API I am actually calling, so that generated clients work and I am not reverse-engineering endpoints from the portal.
Acceptance criteria
  1. Given the spec generator runs, when it completes, then it produces a spec from the routers and schemas rather than from a hand-maintained file.
  2. Given the current committed docs/openapi.json, when it is compared to the running service, then it is stale at v0.27.2 with 33 paths against a service at v0.33.2 with 47 API routes plus one in img — regenerating it is the core of this story.
  3. Given the spec is regenerated, when it is inspected, then it contains securitySchemes describing the bearer scheme and an operationId on every operation, both of which are missing today.
  4. Given the live production API exposes routes absent from this clone — /search/nearby, /geocode, /search/ai, /ai/assistant/, /direct-pricing/, /billing/plans, /portal-events and others — when the spec is published, then it reflects the deployed surface, not the clone.
  5. Given a router changes and the spec is not regenerated, when CI runs, then the build fails — see QUAL-12.
Implementation notes

The docs package already contains an OpenAPI generator with OpenApiSpecBuilder and JsonSchemaBuilder, both unit-tested; the failure is that its output is not regenerated on release.

Edge cases & risks

A spec with no securitySchemes generates clients that omit the Authorization header entirely, so the first thing a new integrator experiences is a 401. Missing operationId values mean generated method names change whenever the path list is reordered, silently breaking downstream code.

QUAL-08

Make the hand-written docs describe the system that exists

Partial
DeveloperMust
As a Developer, I want the README and the HTTP examples to be accurate, so that my first hour with the platform is not spent calling an endpoint that was never built.
Acceptance criteria
  1. Given docs/docs.http, when it is corrected, then the non-existent POST /auth/pat example is replaced with the real POST /tokens, and the locale default is shown as us to match the code rather than gb.
  2. Given the API README.md, when it is rewritten, then it describes the current monorepo layout (api, docs, img, import, link, scrapers, shared) and every documented script name actually exists in package.json.
  3. Given the portal repository README, when it is corrected, then either the deprecation notice is removed or the repository is genuinely superseded by packages/portal and the newest code lives there — today the notice contradicts the fact that this is the newest local copy, which is the named gap.
  4. Given a documented command is run verbatim by a new joiner, when it executes, then it works.
  5. Given docs change, when the release is cut, then a docs check runs so a stale script name is caught mechanically rather than by a frustrated reader.
Implementation notes

Affects README.md and docs/docs.http in hotel-universe-api and the portal README.md at version 0.68.2; the customer-facing docs site is https://docs.hotel-universe.travel/guide/.

Edge cases & risks

The metasearch locale discrepancy is not cosmetic — a developer trusting the documented gb default will get different pricing and availability than they expect, and the portal itself defaults the picker to gb while the API defaults to us. A README that says "deprecated" on the live codebase is how a new engineer ends up working in the wrong repository for a day.

QUAL-09

Log in a structured, queryable form

Partial
Ubio support agentMust
As a Ubio support agent, I want to find every log line for one customer's failing request, so that I can answer a ticket without escalating to an engineer.
Acceptance criteria
  1. Given a request is handled, when it is logged, then the framework emits a structured record rather than free-text.
  2. Given the record is emitted, when it is inspected, then it carries the route, status, duration, principal type and org id.
  3. Given a customer reports a failure with a timestamp, when I search by org id, then I can retrieve the relevant lines without a code change.
  4. Given an error is logged, when it is inspected, then the stack and the causing operation are present and no bearer token, PAT secret or invitation id appears in any field — this redaction is the named gap.
  5. Given a background job logs, when it does, then the job and list or export id are included so async work is traceable.
Implementation notes

Logging comes from @ubio/framework v17; the gaps are consistent contextual fields across handlers and background work, and explicit redaction given that the invitation id doubles as a credential (SEC-09).

Edge cases & risks

HTTP_INCLUDE_UNPARSED_BODY is part of the env surface, so a misconfiguration can put raw request bodies — including CSV row content and Stripe payloads — into logs. Support has no log access story at all today, which is why every question becomes an engineering escalation.

QUAL-10

Capture what customers actually do in the portal

Built
Ubio sales repShould
As a Ubio sales rep, I want to see which features a trial account has genuinely used, so that my follow-up is about what they tried rather than a guess.
Acceptance criteria
  1. Given a user navigates, when router.afterEach fires on an authed, non-public, named route, then page_view is tracked with the route name.
  2. Given events accumulate, when the 5-second flush interval fires, then they are posted in a batch to POST /portal-events.
  3. Given the tab is closing, when pagehide fires, then the pending batch is flushed with keepalive so the last actions are not lost.
  4. Given a meaningful action occurs, when it is tracked, then it uses the defined vocabulary — for example hotel_search_executed, list_csv_upload:id|details, metasearch_executed, subscription_upgrade_started, welcome_tour_completed.
  5. Given the tracking endpoint is unavailable, when a flush fails, then the portal continues to function and the failure does not surface to the user.
Implementation notes

PortalTrackingManager.track(event, pageOrMetadata?, metadata?); the event vocabulary spans search, list, hotel-detail, metasearch, direct-pricing, AI assistant, token, member, billing and onboarding-tour actions.

Edge cases & risks

Events are fire-and-forget with no delivery guarantee, so funnel numbers under-report for users who close tabs quickly. Nothing defines which events are required when a new feature ships, so coverage decays — QUAL-22's definition of done addresses that.

QUAL-11

Replace the mock AI recommendations screen

Partial
Trial userShould
As a Trial user, I want every screen I can reach to show real data about my account, so that I do not base a purchasing decision on a demo mock.
Acceptance criteria
  1. Given /ai/recommendations is reachable by URL, when it renders today, then it displays a hard-coded recommendations array and makes no API calls — this is the defect.
  2. Given the fix ships, when the route loads, then it either calls a real recommendations endpoint scoped to my organisation, or the route is removed and the catch-all redirect handles it.
  3. Given the route is kept and no recommendations exist, when it renders, then it shows a proper EmptyState rather than fabricated content.
  4. Given the route is removed, when a bookmark to it is opened, then the catch-all /:path(.) redirect sends the user to Home without an error.
  5. Given any other screen ships with placeholder data, when CI runs, then a check flags hard-coded fixture arrays in view components.
Implementation notes

views/AiRecommendations.vue is not linked from NavigationManager.getNavItems(), making it an unlisted demo screen that is nevertheless routable for any authenticated user.

Edge cases & risks

An unlinked-but-reachable mock is a credibility risk in a demo or a trial, particularly next to the genuinely working AiSearch and AiAssistant screens. Deleting it may break a sales demo script, so the removal needs a heads-up.

QUAL-12

Fail the build when the spec drifts from the routers

Proposed
DeveloperMust
As a Developer, I want the published contract to be mechanically guaranteed to match the code, so that "the docs are stale" stops being a recurring answer.
Acceptance criteria
  1. Given CI runs on a pull request, when the spec is regenerated from the routers and schemas, then it is compared to the committed docs/openapi.json.
  2. Given the generated and committed specs differ, when the check completes, then the build fails with a diff naming the added, removed or changed operations.
  3. Given a route is added without a schema, when the generator runs, then it fails rather than emitting an operation with an untyped body.
  4. Given the spec changes in a backwards-incompatible way, when the diff is classified, then the check requires an explicit acknowledgement rather than passing silently.
  5. Given the check passes, when a release is tagged, then the regenerated spec is what gets published to the docs site.
Implementation notes

Builds directly on the docs package's OpenApiSpecBuilder and JsonSchemaBuilder, and on the airtight schemas that already describe request and response shapes.

Edge cases & risks

The live production API is ahead of this repository, so a contract test only guarantees consistency within the clone unless the live routes are brought into the same codebase. Endpoints without airtight schemas — the proxied metasearch response, for one — will need shapes written before the gate can be turned on.

QUAL-13

Test the CSV-upload-to-export journey end to end

Proposed
OTA supply managerMust
As an OTA supply manager, I want the whole path from uploading my supply file to downloading an export covered by an automated test, so that the platform's core promise cannot break unnoticed.
Acceptance criteria
  1. Given the test runs, when it starts, then it signs in, uploads a CSV to POST /lists/upload, waits for the list to reach ready, and asserts the entry and hotel counts against GET /lists/{listId}/stats.
  2. Given the list is ready, when the test requests a full export, then it polls until 200 and downloads and decompresses the object, asserting record count and schema.
  3. Given a row cannot be matched, when the test inspects the list, then the entry appears with huId: null and shows in the unmatched view rather than being silently dropped.
  4. Given the CSV is not text/csv, when it is uploaded, then the test asserts the 406 UploadListError with the expected and received content types.
  5. Given the journey is run against the portal, when it executes in a browser driver, then it also covers the ListNew.vue upload modes and the exports drawer.
Implementation notes

Covers the landing page's headline claim — "Bring your list. See every rate." — and the flow copy "Upload a CSV of the hotels you care about. No need to reshape it first — extra columns are ignored."

Edge cases & risks

The journey spans async processing with a 2.5-second portal poll and a fire-and-forget backend job, so the test needs generous timeouts and will be the first thing to go flaky. It also exercises GCS, so CI needs a bucket or an emulator.

QUAL-14

Hold every scraper parser to a golden file

Proposed
Ubio data ops engineerMust
As a Ubio data ops engineer, I want each parser checked against a stored input and an expected output, so that a source changing its markup is caught as a failing test rather than as silently degraded data.
Acceptance criteria
  1. Given a captured response for each source, when the parser runs in CI, then its output is compared field by field against the stored golden file.
  2. Given the parser output changes, when the diff is produced, then it names the changed fields so a reviewer can decide whether it is a fix or a regression.
  3. Given a golden file is intentionally updated, when the change is committed, then the update is reviewed as a deliberate act rather than auto-accepted.
  4. Given a live canary run fetches a real page for a known hotel, when the shape no longer matches the golden fixture, then an alert fires naming the source.
  5. Given a parser produces an empty result for a fixture that previously produced data, when the test runs, then it fails rather than passing on a benign-looking empty output.
Implementation notes

Extends the existing GoogleTravelHtmlParser and BookingScraper unit tests to full coverage: Google Travel HTML and embedded-JSON parsers, the Booking mobile-app JSON endpoints and /dml/graphql facilities query, the TripAdvisor persisted GraphQL queries, and AddressSplittingService.

Edge cases & risks

Captured fixtures contain scraped third-party content and possibly reviewer names, so they need the same PII treatment as exports. Silent degradation is the real risk here: a parser that starts returning half the amenities still writes successfully into sourceHotelData5 and quietly wins the merge for its priority group.

QUAL-15

Regression-test the canonical merge for data quality

Proposed
Customer data engineerMust
As a Customer data engineer, I want the merge's behaviour pinned by tests over a representative fixture set, so that my catalogue does not shift under me between releases.
Acceptance criteria
  1. Given a fixture set of multi-source hotels, when combineData runs, then the resulting _dsNames provenance per field group matches an expected snapshot.
  2. Given a lower-priority source carries a field already claimed by a higher-priority source, when the merge runs, then the test asserts the claimed value is untouched, in DS_NAMES order GoogleHotels → GooglePlaces → Booking → TripAdvisor → Expedia.
  3. Given the image path is exercised, when images are merged, then the test asserts width, height and caption survive — the known bug where encodeImageUrls drops them while ImageUpdateService preserves them must fail this test until fixed.
  4. Given the huId resolution ladder is exercised, when a source doc shares a secondary id such as googlePlaceId with another, then the test asserts the expected rung of the ladder resolves it.
  5. Given computeMeta runs, when a source that previously supplied images or reviews stops doing so, then the sticky availability behaviour is asserted explicitly rather than assumed.
Implementation notes

Extends the existing HotelDataUpdateService and CanonicalDataRepo unit tests with a fixture corpus; the entity-resolution confidence formula (nameSimilarity*0.2 + addressSimilarity*0.2 + postalCodeMatch*0.3 + locationMatch*0.3, accept at ≥ 0.7) deserves its own labelled test set.

Edge cases & risks

The known limitation that "123 Main St" and "124 Main St" are not well distinguished means a threshold test set will encode a false-positive rate we currently do not measure. Changing the merge priority or the acceptance threshold is a catalogue-wide change with no way to preview its blast radius today.

QUAL-16

Catch visual regressions in the portal

Proposed
Ubio platform engineerCould
As a Ubio platform engineer, I want key screens compared against reference screenshots, so that a token or Tailwind change does not quietly break the layout in dark mode.
Acceptance criteria
  1. Given the visual suite runs, when it executes, then it captures search results, hotel detail, list view, the price matrix, billing and the AI assistant in both light and dark themes.
  2. Given a captured screen differs beyond a threshold, when the check completes, then the build fails with a side-by-side diff.
  3. Given the change is intentional, when the baseline is updated, then the update is an explicit reviewed commit.
  4. Given the suite runs at multiple widths, when it does, then it includes a width below 992 px, since the onboarding tour is deliberately skipped there and mobile layout has its own drawer behaviour.
  5. Given a component renders a skeleton, when the screenshot is taken, then loading states are captured deterministically rather than producing flaky diffs.
Implementation notes

Targets the custom "Cosmic Calm" Aura preset (OKLCH yellow primary with warm-neutral and warm-charcoal surface ramps) and the 1904-line portal.css, where a token change has wide and non-obvious reach.

Edge cases & risks

Screenshot tests on data-driven screens are flaky unless the fixture data is frozen, and the hotel detail page renders whatever the catalogue currently holds. Chart.js canvases in HotelPriceInsights.vue and AiDataInsights.vue render differently across platforms and may need exclusion.

QUAL-17

Monitor the critical journeys synthetically

Proposed
Ubio support agentShould
As a Ubio support agent, I want to know a customer journey is broken before the customer tells me, so that the first message we send is an update rather than an apology.
Acceptance criteria
  1. Given synthetic checks run on a schedule, when they execute, then they cover sign-in, a filtered search, a hotel profile load, a small list creation, an export round trip and a metasearch query.
  2. Given a check fails twice consecutively, when the threshold is crossed, then an alert routes to the on-call owner naming the journey and the step that failed.
  3. Given a check succeeds but exceeds its latency budget, when the result is recorded, then it feeds the PERF-12 dashboard rather than firing a page.
  4. Given the checks run against production, when they do, then they use a dedicated monitoring organisation whose data and usage never mix with customer stats.
  5. Given a check result changes state, when PERF-22's status page reads the feed, then the component status updates automatically.
Implementation notes

The metasearch check must be budgeted deliberately, since GET /metasearch proxies a real upstream at 2–5 seconds and consumes real allowance.

Edge cases & risks

Synthetic list creation and exports write real data and real GCS objects that need cleaning up, or the monitoring org accumulates thousands of stale lists. Access logs from the monitoring principal will pollute GET /stats/ unless the org is excluded.

QUAL-18

Trace a request end to end with an id support can quote

Proposed
Ubio support agentShould
As a Ubio support agent, I want a request id I can ask a customer for and follow through every service, so that "it failed at 14:32" becomes an exact trace.
Acceptance criteria
  1. Given a request enters the API, when it is handled, then a request id is generated or accepted from the caller and attached to every log line for that request.
  2. Given the request calls the metasearch service, the Google Places API or a NodeScript endpoint, when the outbound call is made, then the id is propagated and the upstream span is recorded.
  3. Given a request fails, when the error response is returned, then it includes the request id in the body and the portal surfaces it in the error state so a user can quote it.
  4. Given a background job is enqueued by a request, when the job runs, then it carries the originating request id so async failures link back to the user action.
  5. Given I search by request id, when results return, then I see the full span tree including database and upstream timings.
Implementation notes

Extends QUAL-09's structured logging with trace context; the portal's existing ErrorState.vue and toast paths are the natural places to surface the id.

Edge cases & risks

Accepting a caller-supplied request id without validation lets a client poison log search, so it must be sanitised and length-bounded. Traces will contain hotel ids and org ids, so retention and access follow the same rules as the audit log.

QUAL-19

Run on error budgets with routed alerts

Proposed
Ubio adminShould
As a Ubio admin, I want alerts tied to a budget rather than to raw thresholds, so that we are paged for things that matter and can trade reliability against shipping speed deliberately.
Acceptance criteria
  1. Given the SLOs from PERF-12, when error budgets are derived, then each critical journey has a stated budget over a rolling window.
  2. Given budget burn accelerates past a threshold, when the alert fires, then it routes to a named owner with the journey and the burn rate.
  3. Given the budget for a period is exhausted, when the policy applies, then feature work on that surface pauses in favour of reliability work until it recovers.
  4. Given an alert fires outside working hours, when it routes, then the escalation path is defined and matches the incident procedure in SEC-22.
  5. Given an alert has fired more than a defined number of times without action, when the review runs, then the alert is retuned or deleted rather than being routinely ignored.
Implementation notes

Depends on PERF-12's measurement and QUAL-17's synthetics as the two signal sources; alert routing should reuse whatever on-call rota the incident process defines.

Edge cases & risks

Error budgets are only meaningful if someone is empowered to stop feature work, otherwise they become a dashboard nobody reads. Alerting on a proxied endpoint like metasearch charges us for an upstream's unreliability unless upstream time is excluded from the budget.

QUAL-20

Roll releases out in stages behind flags

Proposed
Ubio platform engineerShould
As a Ubio platform engineer, I want to expose a change to a subset of organisations first, so that a bad release affects a handful of tenants rather than all of them.
Acceptance criteria
  1. Given a feature flag is defined, when it is evaluated, then it can be targeted by organisation, by tier and by internal-user status, and its state is visible in one place.
  2. Given a new feature is released behind a flag, when it is enabled for internal users only, then external organisations see the previous behaviour.
  3. Given a rollout is widened, when it moves from internal to a pilot cohort to everyone, then each step is recorded and reversible without a deploy.
  4. Given a flag is turned off during an incident, when it is disabled, then the change takes effect within a stated time without requiring a release.
  5. Given a flag has been fully rolled out and stable, when the cleanup review runs, then the flag and its dead branch are removed.
Implementation notes

Today there is no flag system at all — gating is tier plus route meta (requiresAuth, requiresMetasearchAccess, requiresUbioAccess) plus an email-domain check, per the portal's own gating summary. A flag service would sit alongside AccessManager and be evaluated in the same refreshAccessInfo call.

Edge cases & risks

Flags evaluated client-side only are not a security boundary — anything sensitive must also be enforced server-side, exactly as requiresMetasearchAccess is backed by a 402. Flag sprawl becomes its own maintenance cost without the cleanup step.

QUAL-21

Write down how we release and how we roll back

Proposed
Ubio platform engineerMust
As a Ubio platform engineer, I want the release and rollback procedure documented and rehearsed, so that reverting a bad deploy at 6pm is a checklist rather than an improvisation.
Acceptance criteria
  1. Given a release is planned, when the procedure is followed, then it states who approves, what is verified before tagging, and what smoke checks run after the deploy lands.
  2. Given a release must be rolled back, when the procedure is followed, then it names the exact steps to redeploy the previous image digest for both hotel-universe-api and hotel-universe-scraping-service.
  3. Given a release included a database migration, when rollback is considered, then the procedure states whether the migration is backwards-compatible and what to do if it is not.
  4. Given the API and portal versions can diverge, when a rollback happens, then the compatible portal version is identified so the pair is consistent.
  5. Given a rollback is executed, when it completes, then the smoke checks from step one are re-run to confirm recovery.
Implementation notes

Formalises what the existing tag-push workflow and ubio/infrastructure dispatch already do; the version pair is already visible to anyone via ProfileMenu.vue's Portal: v… / API: v… footer.

Edge cases & risks

Rolling back the API without the portal can break the portal's API client, since it calls endpoints — /search/ai, /ai/assistant/, /direct-pricing/ — that only exist in newer API builds. Any migration that rewrites canonicalData6 is effectively irreversible and should be treated as forward-fix-only.

QUAL-22

Make docs and telemetry part of the definition of done

Proposed
Ubio adminShould
As a Ubio admin, I want a feature to be considered finished only when it is documented, measured and tested, so that we stop accumulating exactly the drift this epic is cataloguing.
Acceptance criteria
  1. Given a story is proposed for done, when the checklist is applied, then it requires tests at the appropriate level, a regenerated OpenAPI spec where the API changed, a docs update where behaviour changed, and at least one telemetry event where a user-facing action was added.
  2. Given a new portal screen ships, when it is reviewed, then it has an EmptyState, an ErrorState and a loading skeleton, per the portal's existing conventions.
  3. Given a new endpoint ships, when it is reviewed, then it has an authentication check, an org scope, a schema and a defined SLO.
  4. Given a checklist item is deliberately skipped, when the story is closed, then the exception is recorded with a reason and an owner rather than silently omitted.
  5. Given the checklist is not met, when the pull request is opened, then the template makes the omission visible to the reviewer.
Implementation notes

Encodes what already exists informally in .cursor/rules and the portal README conventions, and closes the loop with QUAL-07, QUAL-12 and QUAL-10 so specs, docs and telemetry stay current by default.

Edge cases & risks

A definition of done with no enforcement is a wish; the items that can be mechanised — spec drift, missing SLO, missing auth check — should become CI checks, and only the judgement calls should stay on the checklist.

Appendices

Defects, open questions and a first slice

Appendix A

Known defects and inconsistencies found while writing this backlog

These are things the code or the live site says that do not line up. Each has at least one story attached; they are collected here so they are not lost in 742 rows.

Security

#FindingWhere
A1GET /scrapers/google, /scrapers/booking, /scrapers/booking/rooms have no authentication or permission check at all — anyone can drive the scrapers.ScrapersRouter.ts
A2POST /sources accepts a full SourceHotelData payload with no auth check — the @Middleware({ ignorePaths: ['/sources'] }) decorator sits directly above the handler.SourcesRouter.ts
A3GET /img/resize is unauthenticated and its Cache-Control header is commented out.packages/img/src/main/ImgRouter.ts
A4Invitation management is gated by Permission.LISTS_MANAGE, not ORGS_MANAGE — anyone who can build a list can invite people into the organisation.InvitationRouter.ts
A5The invitation id is the bearer credential, delivered in a plain email link to /invite/accept?token=, and the accept endpoint is unauthenticated.InvitationRouter.ts, EmailService.ts
A6Permission.USERS_MANAGE is declared but never enforced anywhere. orgs:members:manage and orgs:members:view are commented out.schemas/Permission.ts
A7There are no per-user roles. Every member of an organisation holds an identical scope set; membership is a flat memberIds array.OrganizationService, JWT issuance
A8The internal super-user check is principal.email.includes('@ub.io') — a substring match on an email address grants cross-organisation read access.HuAuthContext.isUbee()
A9The huId salt ('31b54e6e4b#') and the AES-256-CBC image-URL encryption key are hard-coded in source.SourceHotelDataRepo, utils/encodeImageUrl.ts
A10All scraper proxies are constructed with rejectUnauthorized: false.packages/scrapers remotes
A11PATs never expire, cannot be scoped at creation, and there is no rotation flow.TokensRouter.ts, HuAuthProvider.ts
A12Stripe webhook handler errors are caught and swallowed so Stripe always receives a 200 — a failed tier update is invisible.StripeWebhookService.ts

Correctness and consistency

#FindingWhere
B1An empty search query resolves to $match: { _id: 'impossible-id' }, so a filterless search silently returns nothing rather than a first page or a clear error.CanonicalDataRepo
B2The rate limiter is implemented and Redis-backed but is applied to GET /metasearch only. Every other endpoint is unlimited.RateLimitService, MetasearchRouter
B3HotelDataUpdateService.encodeImageUrls drops width, height and caption, while ImageUpdateService preserves them — the same image gets different fidelity depending on the ingest path.shared/services
B4The Stripe webhook resolves an organisation by organizationsRepo.findAll().find(...) — a full collection scan on every event.StripeWebhookService.ts
B5Metasearch usage is not logged when the caller passes a googleHotelId directly — an explicit TODO: Track usage also when googleHotelId used.MetasearchRouter.ts
B6The trial hotel-view quota counts distinct hotels all-time, not per period, so a trial can never recover headroom.AccessTierService
B7pro, pro_plus and ultra all map to the same access tier — the code carries a TODO: Decide whether / how much to gate based on tier / subscription plan.StripeWebhookService.updateOrganizationTier
B8Errored scrape records are never retried separately; getStaleHotels only picks up null and stale success statuses. Marked TODO in code.AbstractScrapingService
B9The canonical huId is generated only from GoogleHotels ids, so canonical identity is structurally anchored to one source.SourceHotelDataRepo.generateHuId
B10A canonical list of ~280 FEATURE_TITLES exists but is not enforced on the type, so amenity titles are not actually constrained.shared feature schemas
B11The landing page promises "Export to CSV / JSON or push diffs to a webhook", but exports are gzipped JSON only and no outbound webhook feature exists.Landing copy vs ExportService
B12Import batch sizes and enabled sources are hard-coded in app.ts and toggled by commenting lines in and out.packages/import/app.ts

Documentation

#FindingWhere
C1docs/openapi.json is stale: info.version 0.27.2 against a 0.33.2 codebase, 33 paths instead of 47, no securitySchemes, no operationIds, and several routes rendered with a * method.docs/openapi.json
C2docs/docs.http documents POST /auth/pat, which does not exist — the real endpoint is POST /tokens. It also documents a locale default of gb where the code uses us.docs/docs.http
C3The API README.md describes a pre-monorepo src/apps/* layout and script names (app:api, app:import, app:link, app:docs) that no longer match package.json.README.md
C4The hotel-universe-portal repository declares itself deprecated in favour of packages/portal in the API monorepo, yet it is the most recent portal source available locally.portal README.md
C5/ai/recommendations ships hard-coded mock data and is not linked from navigation.AiRecommendations.vue

Appendix B

Open questions for the product team

  1. Which plan model is current? The API clone has pro | pro_plus | ultra with a single trial | full tier; the live portal has three products (smartFeed, metasearch, richContent), five plan tiers (experience | consume | deliver | grow | more) and a paid access tier. Several stories are written against the live model; confirm which is authoritative before planning.
  2. Is the subscription unit the hotel or the list? The landing page says "Subscribe the list for 365 days"; the portal subscribes individual hotels and counts them against a rich-content quota. The difference materially changes the quota, expiry and renewal stories.
  3. What is the intended canonical-ID strategy? Anchoring huId to Google Hotels is simple but makes the whole catalogue dependent on one upstream source. A source-agnostic ID is proposed in LINK; it is a large change and needs a decision.
  4. What is the legal position on scraping? Booking and TripAdvisor access uses spoofed mobile-app identities, generated cookies and proxies. Several SCRP and SEC stories assume a review has happened or will.
  5. Who is the buyer for Direct ARI? The linking flow currently requires Ubio staff (requiresUbioAccess, /private/direct-pricing/*). Self-serve linking is proposed in DARI, but it may be intentional that this stays a managed service.
  6. Is the trial meant to convert self-serve? Today the only sign-in is Google OAuth, the pricing page requires signing in to see tiers, and the site is noindex. Several MKTG stories assume a self-serve motion; if the motion is sales-led, those drop in priority.
  7. What freshness are we actually committing to? "365 days of automatic updates" is a marketing claim; the scraper's staleness threshold is 30 days with no prioritisation for subscribed hotels. SUBS and SCRP propose closing that gap — the target cadence needs to be a decision, not an inference.
  8. How much of the AI assistant is load-bearing? It is now the default search mode. Its quality, cost and failure modes are largely untested in the code that was reviewed, and there is no evaluation harness.

Appendix C

Suggested first slice

If the backlog needs to become a plan, the following ordering reflects the risk and value observed while writing it.

Now — close the holes. SEC-01SEC-06 (unauthenticated endpoints, invitation scoping, real roles), DEV-16 (rate limits beyond metasearch), B1 above (empty-search behaviour), QUAL-12QUAL-14 (OpenAPI drift and docs accuracy). None of these are features; all of them are things a serious customer's security review will find.

Next — make the core promise true. DELV outbound webhooks and EXPT CSV output, because the landing page already sells both. SUBS expiry and renewal, because a 365-day subscription with no expiry warning is a churn event waiting to happen. MTCH threshold control and multi-candidate review, because match quality is the product's first impression.

Then — deepen the differentiator. PINS saved comp sets and scheduled rate shopping, DARI self-serve linking and refresh cadence, MCP remote OAuth server and per-tool scopes. This is the part competitors cannot copy quickly.

Throughout — instrument it. USGE real-time quotas and per-token usage, PERF SLOs and load testing, TRST data-quality dashboards. Everything above is easier to prioritise once these exist.