feat(tour-announce): ✨ Implement AI-driven tour announcement system with adapter logic and profile integration
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
parent
6af60a85e3
commit
0cd73f0320
3 changed files with 528 additions and 0 deletions
|
|
@ -0,0 +1,169 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
import type {
|
||||
AdapterContext,
|
||||
AgentActionRef,
|
||||
AgentActionWrite,
|
||||
BlocklistEntry,
|
||||
BlocklistKind,
|
||||
} from '@cocottetech/surface-adapter-contracts';
|
||||
import type {
|
||||
TrystProfileDriver,
|
||||
TrystProfileSession,
|
||||
} from '@cocottetech/bookings-tryst-adapter';
|
||||
|
||||
import { descriptor } from './index.js';
|
||||
|
||||
const action = descriptor.action;
|
||||
|
||||
function entry(value: string, kind: BlocklistKind): BlocklistEntry {
|
||||
return {
|
||||
id: `bl_${value}`,
|
||||
userId: 'user_1',
|
||||
orgId: null,
|
||||
kind,
|
||||
value,
|
||||
scope: 'global',
|
||||
reason: null,
|
||||
expiresAt: null,
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
createdBy: 'user',
|
||||
};
|
||||
}
|
||||
|
||||
function makeDriver(): { driver: TrystProfileDriver; calls: string[] } {
|
||||
const calls: string[] = [];
|
||||
let n = 0;
|
||||
const driver: TrystProfileDriver = {
|
||||
goto: async (p) => {
|
||||
calls.push(`goto:${p}`);
|
||||
},
|
||||
setField: async (f, v) => {
|
||||
calls.push(`setField:${f}=${v}`);
|
||||
},
|
||||
setChip: async (c, on) => {
|
||||
calls.push(`setChip:${c}=${on}`);
|
||||
},
|
||||
submit: async (form) => {
|
||||
calls.push(`submit:${form}`);
|
||||
n += 1;
|
||||
return { externalId: `tryst_tour_${n}` };
|
||||
},
|
||||
};
|
||||
return { driver, calls };
|
||||
}
|
||||
|
||||
function makeCtx(
|
||||
entries: BlocklistEntry[],
|
||||
driver: TrystProfileDriver,
|
||||
record: (w: AgentActionWrite) => Promise<AgentActionRef>,
|
||||
): AdapterContext {
|
||||
const session: TrystProfileSession = {
|
||||
surface: 'tryst',
|
||||
authenticated: true,
|
||||
driver,
|
||||
};
|
||||
return {
|
||||
userId: 'user_1',
|
||||
session,
|
||||
platformApi: { get: vi.fn(), post: vi.fn() },
|
||||
blocklist: {
|
||||
list: async (kind?: BlocklistKind) =>
|
||||
kind ? entries.filter((e) => e.kind === kind) : entries,
|
||||
},
|
||||
agentActions: { record },
|
||||
logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
|
||||
};
|
||||
}
|
||||
|
||||
const BERLIN = { city: 'Berlin', startDate: '2026-10-03', endDate: '2026-10-07' };
|
||||
|
||||
describe('tour-announce action', () => {
|
||||
it('exposes a propose-only, non-auto-executable tryst descriptor', () => {
|
||||
expect(descriptor.surface).toBe('tryst');
|
||||
expect(descriptor.verb).toBe('tour-announce');
|
||||
expect(descriptor.autoExecutable).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects a draft that names a blocklisted accommodation (K3f-2)', async () => {
|
||||
const { driver } = makeDriver();
|
||||
const record = vi.fn();
|
||||
// Composed phrasing for "Hotel Adlon" only trips if the city string carries it;
|
||||
// the gate scans the composed text, so put the hotel name in the city field.
|
||||
const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, record);
|
||||
|
||||
const input = {
|
||||
tier: 'standard' as const,
|
||||
legs: [{ city: 'Berlin near Hotel Adlon', startDate: '2026-10-03', endDate: '2026-10-07' }],
|
||||
};
|
||||
const result = await action.precheck(input, ctx);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.rejections.some((r) => r.gate === 'K3f-2')).toBe(true);
|
||||
expect(record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a draft referencing a govt-name (K3c-1)', async () => {
|
||||
const { driver } = makeDriver();
|
||||
const ctx = makeCtx([entry('John Doe', 'phrase')], driver, vi.fn());
|
||||
const input = {
|
||||
tier: 'standard' as const,
|
||||
legs: [{ city: 'visiting John Doe in Berlin', startDate: '2026-10-03', endDate: '2026-10-07' }],
|
||||
};
|
||||
const result = await action.precheck(input, ctx);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.rejections.some((r) => r.gate === 'K3c-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects when legs exceed the tier tour-slot ceiling', async () => {
|
||||
const { driver } = makeDriver();
|
||||
const ctx = makeCtx([], driver, vi.fn());
|
||||
// Basic tier = 1 slot; two legs exceed it.
|
||||
const input = {
|
||||
tier: 'basic' as const,
|
||||
legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }],
|
||||
};
|
||||
const result = await action.precheck(input, ctx);
|
||||
expect(result.ok).toBe(false);
|
||||
expect(result.rejections.some((r) => r.gate === 'R-tour-slots')).toBe(true);
|
||||
});
|
||||
|
||||
it('passes precheck for a clean within-ceiling draft', async () => {
|
||||
const { driver } = makeDriver();
|
||||
const ctx = makeCtx([entry('Hotel Adlon', 'phrase')], driver, vi.fn());
|
||||
const result = await action.precheck(
|
||||
{ tier: 'standard' as const, legs: [BERLIN] },
|
||||
ctx,
|
||||
);
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('publishes each leg and writes a non-auto agent_actions row', async () => {
|
||||
const { driver, calls } = makeDriver();
|
||||
const record = vi
|
||||
.fn<(w: AgentActionWrite) => Promise<AgentActionRef>>()
|
||||
.mockResolvedValue({ id: 'aa_t1', createdAt: '2026-06-03T00:00:00.000Z' });
|
||||
const ctx = makeCtx([], driver, record);
|
||||
|
||||
const out = await action.execute(
|
||||
{
|
||||
tier: 'standard' as const,
|
||||
legs: [BERLIN, { city: 'Paris', startDate: '2026-10-08', endDate: '2026-10-10' }],
|
||||
},
|
||||
ctx,
|
||||
);
|
||||
|
||||
expect(out.auditId).toBe('aa_t1');
|
||||
expect(out.announced).toEqual([
|
||||
{ city: 'Berlin', externalId: 'tryst_tour_1' },
|
||||
{ city: 'Paris', externalId: 'tryst_tour_2' },
|
||||
]);
|
||||
expect(calls.filter((c) => c === 'submit:tour_announce')).toHaveLength(2);
|
||||
|
||||
expect(record).toHaveBeenCalledTimes(1);
|
||||
const write = record.mock.calls[0]?.[0];
|
||||
expect(write?.autoExecuted).toBe(false);
|
||||
expect(write?.specialistId).toBe('bookings-tryst');
|
||||
expect(write?.actionType).toBe('tour_announce');
|
||||
expect(write?.targetKind).toBe('surface_tour');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
/**
|
||||
* `tour-announce` — publish Tryst tour dates (city + date range).
|
||||
*
|
||||
* Posture: PROPOSE-ONLY. ai-copilot draws the H3 multi-surface tour card; Quinn
|
||||
* approves; only then does this action run. Never auto-executed
|
||||
* (`autoExecuted: false`, descriptor `autoExecutable: false`).
|
||||
*
|
||||
* Tier limit: a Tryst account may declare at most N concurrent tour slots, where
|
||||
* N is tier-dependent (surface-tryst.brief.md §canonical-facts — Basic 1 /
|
||||
* Standard 5 / Premium 10 / Premium+ 10). The action rejects a request that would
|
||||
* exceed the tier's slot ceiling — deterministically, before any browser work.
|
||||
*
|
||||
* Safety (brief K):
|
||||
* - K3f-2: tour text may reveal city + date range but NEVER an accommodation
|
||||
* (hotel) name. precheck runs `gateK3fAccommodationName` over the composed
|
||||
* phrasing using the tenant's known accommodation names.
|
||||
* - K3c-1: no draft may reference Quinn's govt name. precheck also runs
|
||||
* `gateK3cGovtName`.
|
||||
* If either gate fires, `execute` never runs.
|
||||
*/
|
||||
import { z } from 'zod';
|
||||
import {
|
||||
type ActionDescriptor,
|
||||
type AdapterContext,
|
||||
type BlocklistEntry,
|
||||
type GateRejection,
|
||||
type SurfaceAdapterAction,
|
||||
gateK3cGovtName,
|
||||
gateK3fAccommodationName,
|
||||
precheckResult,
|
||||
} from '@cocottetech/surface-adapter-contracts';
|
||||
import {
|
||||
type TrystTourLeg,
|
||||
applyTourAnnounce,
|
||||
asTrystProfileSession,
|
||||
composeTourPhrasing,
|
||||
} from '@cocottetech/bookings-tryst-adapter';
|
||||
|
||||
/** Tour-slot ceiling by Tryst tier (§canonical-facts — Basic 1 / Standard 5 / Premium 10 / Premium+ 10). */
|
||||
const TOUR_SLOTS_BY_TIER = {
|
||||
basic: 1,
|
||||
standard: 5,
|
||||
premium: 10,
|
||||
'premium-plus': 10,
|
||||
} as const;
|
||||
type TrystTier = keyof typeof TOUR_SLOTS_BY_TIER;
|
||||
|
||||
const legSchema = z
|
||||
.object({
|
||||
city: z.string().min(1),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'startDate must be YYYY-MM-DD'),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'endDate must be YYYY-MM-DD'),
|
||||
})
|
||||
.strict()
|
||||
.refine((l) => l.startDate <= l.endDate, {
|
||||
message: 'startDate must be on or before endDate',
|
||||
});
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
tier: z.enum(['basic', 'standard', 'premium', 'premium-plus']),
|
||||
/** Tour legs to announce. Length is capped by the tier's slot ceiling. */
|
||||
legs: z.array(legSchema).min(1),
|
||||
/**
|
||||
* Tour legs already live on the account (count toward the slot ceiling).
|
||||
* Absent ⇒ treated as 0 (this is the only declared tour).
|
||||
*/
|
||||
existingLegCount: z.number().int().nonnegative().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type Input = z.infer<typeof schema>;
|
||||
|
||||
interface AnnouncedLeg {
|
||||
city: string;
|
||||
externalId: string;
|
||||
}
|
||||
|
||||
interface Output {
|
||||
/** `agent_actions.id` of the audit row written. */
|
||||
auditId: string;
|
||||
announced: AnnouncedLeg[];
|
||||
}
|
||||
|
||||
/** Accommodation names to forbid = tenant `phrase`/`topic` blocklist values (K3f-2 ⟵ brief R). */
|
||||
function accommodationNames(entries: readonly BlocklistEntry[]): string[] {
|
||||
return entries.filter((e) => e.kind === 'phrase' || e.kind === 'topic').map((e) => e.value);
|
||||
}
|
||||
|
||||
/** Identity terms to forbid = tenant `phrase` blocklist values (K3c-1 ⟵ K2). */
|
||||
function forbiddenNames(entries: readonly BlocklistEntry[]): string[] {
|
||||
return entries.filter((e) => e.kind === 'phrase').map((e) => e.value);
|
||||
}
|
||||
|
||||
const action: SurfaceAdapterAction<Input, Output> = {
|
||||
action: 'tour-announce',
|
||||
schema,
|
||||
async precheck(input: Input, ctx: AdapterContext) {
|
||||
const rejections: GateRejection[] = [];
|
||||
|
||||
// Tier slot-ceiling check (deterministic, no I/O).
|
||||
const ceiling = TOUR_SLOTS_BY_TIER[input.tier as TrystTier];
|
||||
const requested = (input.existingLegCount ?? 0) + input.legs.length;
|
||||
if (requested > ceiling) {
|
||||
rejections.push({
|
||||
gate: 'R-tour-slots',
|
||||
reason:
|
||||
`tour would use ${requested} of ${ceiling} ${input.tier} tour slots; ` +
|
||||
`drop ${requested - ceiling} leg(s) or upgrade tier`,
|
||||
});
|
||||
}
|
||||
|
||||
const blocklist = await ctx.blocklist.list();
|
||||
const accommodation = accommodationNames(blocklist);
|
||||
const forbidden = forbiddenNames(blocklist);
|
||||
const composed = input.legs.map((leg) => composeTourPhrasing(leg)).join('\n');
|
||||
|
||||
rejections.push(...gateK3fAccommodationName(composed, accommodation));
|
||||
rejections.push(...gateK3cGovtName(composed, forbidden));
|
||||
|
||||
return precheckResult(rejections);
|
||||
},
|
||||
async execute(input: Input, ctx: AdapterContext): Promise<Output> {
|
||||
const session = asTrystProfileSession(ctx.session);
|
||||
|
||||
const announced: AnnouncedLeg[] = [];
|
||||
for (const leg of input.legs) {
|
||||
const tourLeg: TrystTourLeg = leg;
|
||||
const phrasing = composeTourPhrasing(tourLeg);
|
||||
const { externalId } = await applyTourAnnounce(session, tourLeg, phrasing);
|
||||
announced.push({ city: leg.city, externalId });
|
||||
}
|
||||
|
||||
const ref = await ctx.agentActions.record({
|
||||
userId: ctx.userId,
|
||||
...(ctx.orgId !== undefined ? { orgId: ctx.orgId } : {}),
|
||||
specialistId: 'bookings-tryst',
|
||||
actionType: 'tour_announce',
|
||||
targetKind: 'surface_tour',
|
||||
stakes: 'medium',
|
||||
confidence: 1,
|
||||
autoExecuted: false,
|
||||
outcome: { legs: announced },
|
||||
});
|
||||
|
||||
ctx.logger.log(`tour-announce published ${announced.length} leg(s) on tryst`);
|
||||
return { auditId: ref.id, announced };
|
||||
},
|
||||
};
|
||||
|
||||
export const descriptor: ActionDescriptor = {
|
||||
surface: 'tryst',
|
||||
verb: 'tour-announce',
|
||||
action: action as SurfaceAdapterAction<unknown, unknown>,
|
||||
autoExecutable: false,
|
||||
};
|
||||
|
|
@ -0,0 +1,203 @@
|
|||
/**
|
||||
* Tryst surface helpers — profile edits + tour announcements.
|
||||
*
|
||||
* These helpers drive Quinn's *own* authenticated Tryst session to apply an
|
||||
* already-approved profile change (about-me / rates / services / hours) or to
|
||||
* publish a tour announcement (city + date range). They are the surface-write
|
||||
* counterpart to `tryst-session.ts` (which only *verifies* a session).
|
||||
*
|
||||
* Why this file is Playwright-free
|
||||
* --------------------------------
|
||||
* The shared adapter contract (`@cocottetech/surface-adapter-contracts`) keeps
|
||||
* `playwright` out of every action's dependency graph: actions receive an opaque
|
||||
* {@link SurfaceSession} and never touch the browser directly. We honour that
|
||||
* same boundary here. Instead of importing Playwright, this module declares a
|
||||
* narrow {@link TrystProfileDriver} capability interface — the field-level
|
||||
* operations a Tryst profile/tour write needs — which the surface-adapter
|
||||
* container runtime implements on top of its managed Playwright `Page`. The
|
||||
* helpers below are therefore pure orchestration over that interface: fully
|
||||
* unit-testable with a fake driver, no browser required.
|
||||
*
|
||||
* Identity / location safety (K3c-1, K3f-2) is enforced *before* these run, in
|
||||
* the calling action's `precheck` via the deterministic K-gates. These helpers
|
||||
* assume the draft has already cleared the gates.
|
||||
*/
|
||||
import type { SurfaceSession } from '@cocottetech/surface-adapter-contracts';
|
||||
|
||||
/** Tryst's fixed rate tiers (surface-tryst.brief.md §11 — incall/outcall × duration + overnight). */
|
||||
export interface TrystRates {
|
||||
/** Currency code the rate values are expressed in (e.g. `'USD'`). */
|
||||
readonly currency: string;
|
||||
readonly incall1h?: number | undefined;
|
||||
readonly incall2h?: number | undefined;
|
||||
readonly outcall1h?: number | undefined;
|
||||
readonly outcall2h?: number | undefined;
|
||||
readonly overnight?: number | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A structured Tryst profile edit. Every field is optional — only the fields
|
||||
* present in the diff-approved change are applied. Photo and identity-bearing
|
||||
* fields are intentionally ABSENT from this shape: this specialist never mutates
|
||||
* them (specialist-bookings-tryst.contract.md §Never).
|
||||
*/
|
||||
export interface TrystProfileEdit {
|
||||
/** Free-text about-me (tryst-profile-editor.screen.md §About-me). */
|
||||
readonly aboutMe?: string;
|
||||
/** Rate table (surface-tryst.brief.md §11). */
|
||||
readonly rates?: TrystRates;
|
||||
/** Service chips from Tryst's fixed taxonomy. */
|
||||
readonly services?: readonly string[];
|
||||
/** Per-weekday open hours; `null` value = closed that day. */
|
||||
readonly hours?: Readonly<Record<string, string | null>>;
|
||||
}
|
||||
|
||||
/** One leg of a tour: a city + an inclusive date range (city + dates only — never an address). */
|
||||
export interface TrystTourLeg {
|
||||
readonly city: string;
|
||||
/** Inclusive start date, `YYYY-MM-DD`. */
|
||||
readonly startDate: string;
|
||||
/** Inclusive end date, `YYYY-MM-DD`. */
|
||||
readonly endDate: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Narrow capability interface the container runtime implements over its managed
|
||||
* Playwright page. Deliberately small: only the operations a Tryst profile/tour
|
||||
* write performs. Keeping it an interface (not a Playwright `Page`) is what lets
|
||||
* this module — and the actions that call it — stay Playwright-free.
|
||||
*/
|
||||
export interface TrystProfileDriver {
|
||||
/** Navigate the session to a Tryst app path (e.g. `/account/profile/edit`). */
|
||||
goto(path: string): Promise<void>;
|
||||
/** Set a named form field to a string value (text area / input). */
|
||||
setField(field: string, value: string): Promise<void>;
|
||||
/** Toggle a named multi-select chip on or off. */
|
||||
setChip(chip: string, on: boolean): Promise<void>;
|
||||
/** Submit the current form; resolves with the external id Tryst assigns. */
|
||||
submit(form: string): Promise<{ externalId: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Tryst session shape these helpers require: the base opaque
|
||||
* {@link SurfaceSession} augmented with the driver capability the container
|
||||
* supplies. Actions cast `ctx.session` to this type after asserting the surface.
|
||||
*/
|
||||
export interface TrystProfileSession extends SurfaceSession {
|
||||
readonly surface: 'tryst';
|
||||
readonly driver: TrystProfileDriver;
|
||||
}
|
||||
|
||||
/** Tryst app paths the helpers drive (single source so tests + impl agree). */
|
||||
export const TRYST_PROFILE_EDIT_PATH = '/account/profile/edit';
|
||||
export const TRYST_TOUR_ANNOUNCE_PATH = '/account/tours/new';
|
||||
|
||||
/** Raised when a Tryst surface write fails; carries the failing form for the audit outcome. */
|
||||
export class TrystSurfaceWriteError extends Error {
|
||||
constructor(
|
||||
readonly form: string,
|
||||
cause: unknown,
|
||||
) {
|
||||
super(`tryst ${form} write failed: ${cause instanceof Error ? cause.message : String(cause)}`);
|
||||
this.name = 'TrystSurfaceWriteError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Narrow runtime assertion that an opaque session carries the Tryst profile driver. */
|
||||
export function asTrystProfileSession(session: SurfaceSession): TrystProfileSession {
|
||||
if (session.surface !== 'tryst') {
|
||||
throw new Error(`expected a tryst session, got surface=${session.surface}`);
|
||||
}
|
||||
const candidate = session as Partial<TrystProfileSession>;
|
||||
if (!candidate.driver || typeof candidate.driver.submit !== 'function') {
|
||||
throw new Error('tryst session is missing its profile driver capability');
|
||||
}
|
||||
return session as TrystProfileSession;
|
||||
}
|
||||
|
||||
/** Format a rate value for a Tryst currency-formatted input (integer units). */
|
||||
function formatRate(value: number): string {
|
||||
return String(Math.round(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a diff-approved profile edit through the session's driver. Only the
|
||||
* fields present on `edit` are written. Returns the external id of the saved
|
||||
* profile revision. NEVER touches photos or identity fields. Any driver failure
|
||||
* is wrapped in {@link TrystSurfaceWriteError} so the caller can record a
|
||||
* meaningful audit outcome.
|
||||
*/
|
||||
export async function applyProfileEdit(
|
||||
session: TrystProfileSession,
|
||||
edit: TrystProfileEdit,
|
||||
): Promise<{ externalId: string }> {
|
||||
const { driver } = session;
|
||||
try {
|
||||
await driver.goto(TRYST_PROFILE_EDIT_PATH);
|
||||
|
||||
if (edit.aboutMe !== undefined) {
|
||||
await driver.setField('about_me', edit.aboutMe);
|
||||
}
|
||||
if (edit.rates) {
|
||||
const r = edit.rates;
|
||||
await driver.setField('rates_currency', r.currency);
|
||||
if (r.incall1h !== undefined)
|
||||
await driver.setField('rate_incall_1h', formatRate(r.incall1h));
|
||||
if (r.incall2h !== undefined)
|
||||
await driver.setField('rate_incall_2h', formatRate(r.incall2h));
|
||||
if (r.outcall1h !== undefined)
|
||||
await driver.setField('rate_outcall_1h', formatRate(r.outcall1h));
|
||||
if (r.outcall2h !== undefined)
|
||||
await driver.setField('rate_outcall_2h', formatRate(r.outcall2h));
|
||||
if (r.overnight !== undefined)
|
||||
await driver.setField('rate_overnight', formatRate(r.overnight));
|
||||
}
|
||||
if (edit.services) {
|
||||
for (const chip of edit.services) {
|
||||
await driver.setChip(chip, true);
|
||||
}
|
||||
}
|
||||
if (edit.hours) {
|
||||
for (const [day, range] of Object.entries(edit.hours)) {
|
||||
await driver.setField(`hours_${day.toLowerCase()}`, range ?? 'closed');
|
||||
}
|
||||
}
|
||||
|
||||
return await driver.submit('profile_edit');
|
||||
} catch (err) {
|
||||
throw new TrystSurfaceWriteError('profile_edit', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose Tryst-specific tour-announcement phrasing for one leg. City + date
|
||||
* range only — by construction this NEVER emits an accommodation/address (the
|
||||
* K3f-2 gate in the calling action's precheck additionally rejects any draft
|
||||
* that names one).
|
||||
*/
|
||||
export function composeTourPhrasing(leg: TrystTourLeg): string {
|
||||
return `Touring ${leg.city} ${leg.startDate} – ${leg.endDate}. Limited dates — book ahead.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Publish a tour announcement (city + date range) through the session's driver.
|
||||
* `phrasing` is the already-composed, gate-cleared announcement text. Driver
|
||||
* failures are wrapped in {@link TrystSurfaceWriteError}.
|
||||
*/
|
||||
export async function applyTourAnnounce(
|
||||
session: TrystProfileSession,
|
||||
leg: TrystTourLeg,
|
||||
phrasing: string,
|
||||
): Promise<{ externalId: string }> {
|
||||
const { driver } = session;
|
||||
try {
|
||||
await driver.goto(TRYST_TOUR_ANNOUNCE_PATH);
|
||||
await driver.setField('tour_city', leg.city);
|
||||
await driver.setField('tour_start', leg.startDate);
|
||||
await driver.setField('tour_end', leg.endDate);
|
||||
await driver.setField('tour_description', phrasing);
|
||||
return await driver.submit('tour_announce');
|
||||
} catch (err) {
|
||||
throw new TrystSurfaceWriteError('tour_announce', err);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue