diff --git a/README.md b/README.md
index 3cdd0b5e..985b65f6 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@
-
+
@@ -510,7 +510,7 @@ Operator notes for activating existing adapters, metasearch landings on the dire
| OTA Channels | Booking.com + Expedia (EQC) + SiteMinder + DerbySoft | Direct + aggregated OTA connectivity (ARI + content) |
| XML Processing | fast-xml-parser | Booking.com OTA XML protocol |
| Package Manager | pnpm workspaces | Monorepo management |
-| Testing | Vitest (2407 passing tests across 281 files with passing tests) | Unit and integration tests |
+| Testing | Vitest (2418 passing tests across 281 files with passing tests) | Unit and integration tests |
| Build | tsup (packages) + Vite (dashboard) + nest build (API) | Fast builds |
| Containers | Docker + docker-compose | Local dev and production deployment |
| CI/CD | GitHub Actions | Automated testing, builds, and releases |
@@ -648,7 +648,7 @@ Before going live, verify the items in [`docs/deployment.md`](./docs/deployment.
### Run tests
```bash
-# Passing-test count: 2407 test cases across 281 files (skipped excluded)
+# Passing-test count: 2418 test cases across 281 files (skipped excluded)
# API tests only
pnpm --filter @telivityhaip/api test
@@ -1197,7 +1197,7 @@ HAIP is built in public and contributions are welcome.
pnpm install # Install dependencies
pnpm build # Build all workspace packages
pnpm dev # Start API in dev mode (hot reload)
-pnpm test # Run all tests (2407 passing, 281 files with passes; skipped excluded)
+pnpm test # Run all tests (2418 passing, 281 files with passes; skipped excluded)
pnpm lint # ESLint
```
diff --git a/apps/api/src/modules/agent/review-response/review-response.models.spec.ts b/apps/api/src/modules/agent/review-response/review-response.models.spec.ts
index b1aaa812..f7687fde 100644
--- a/apps/api/src/modules/agent/review-response/review-response.models.spec.ts
+++ b/apps/api/src/modules/agent/review-response/review-response.models.spec.ts
@@ -17,8 +17,21 @@ describe('classifySentiment', () => {
expect(classifySentiment(5)).toBe('positive');
});
- it('classifies 3 as mixed', () => {
+ it('classifies 3 as mixed when cues are balanced or absent', () => {
expect(classifySentiment(3)).toBe('mixed');
+ expect(classifySentiment(3, 'Staff was great but the room was disappointing')).toBe('mixed');
+ });
+
+ it('leans a rating-3 review negative when complaint cues dominate', () => {
+ expect(
+ classifySentiment(3, 'Dirty room, terrible smell, rude service, never again'),
+ ).toBe('negative');
+ });
+
+ it('leans a rating-3 review positive when praise cues dominate', () => {
+ expect(
+ classifySentiment(3, 'Amazing staff, wonderful location, excellent breakfast, loved it'),
+ ).toBe('positive');
});
it('classifies 1-2 as negative', () => {
@@ -73,6 +86,18 @@ describe('extractTopics', () => {
const topics = extractTopics('The bed was extremely comfortable and the bathroom was modern');
expect(topics).toContain('room_quality');
});
+
+ it('does not treat substring false positives as topics', () => {
+ // "bargain" must not match amenities "bar"; "vacations" must not match "ac"
+ const topics = extractTopics('Found a bargain on our vacations package online');
+ expect(topics).not.toContain('amenities');
+ expect(topics).not.toContain('room_quality');
+ });
+
+ it('still matches AC as a whole word for room quality', () => {
+ expect(extractTopics('The AC was broken all night')).toContain('room_quality');
+ expect(extractTopics('The bar was closed')).toContain('amenities');
+ });
});
// ---------------------------------------------------------------------------
@@ -180,7 +205,7 @@ describe('generateResponseDraft', () => {
expect(draft.keyPointsAddressed.length).toBe(0);
});
- it('confidence increases with topic coverage', () => {
+ it('confidence increases with topic coverage but stays modest', () => {
const noTopics = generateResponseDraft(5, 'Nice', 'Ivy', config);
const withTopics = generateResponseDraft(
2,
@@ -188,16 +213,17 @@ describe('generateResponseDraft', () => {
'Jay',
config,
);
- expect(withTopics.confidence).toBeGreaterThanOrEqual(0.70);
+ expect(withTopics.confidence).toBeGreaterThan(noTopics.confidence);
+ expect(withTopics.confidence).toBeLessThanOrEqual(0.85);
});
- it('caps confidence at 0.95', () => {
+ it('caps keyword-based confidence at 0.85', () => {
const draft = generateResponseDraft(
1,
'Dirty, noisy, rude staff, overpriced, terrible food, bad parking, slow wifi',
'Kay',
config,
);
- expect(draft.confidence).toBeLessThanOrEqual(0.95);
+ expect(draft.confidence).toBeLessThanOrEqual(0.85);
});
});
diff --git a/apps/api/src/modules/agent/review-response/review-response.models.ts b/apps/api/src/modules/agent/review-response/review-response.models.ts
index bfdc4257..c6d620a4 100644
--- a/apps/api/src/modules/agent/review-response/review-response.models.ts
+++ b/apps/api/src/modules/agent/review-response/review-response.models.ts
@@ -49,14 +49,73 @@ export interface ReviewResponseConfig {
// Sentiment classification
// ---------------------------------------------------------------------------
-export function classifySentiment(rating: number): ReviewSentiment {
+const POSITIVE_CUES = [
+ 'great',
+ 'amazing',
+ 'wonderful',
+ 'excellent',
+ 'loved',
+ 'perfect',
+ 'friendly',
+ 'clean',
+ 'helpful',
+ 'comfortable',
+ 'recommend',
+];
+
+const NEGATIVE_CUES = [
+ 'dirty',
+ 'terrible',
+ 'awful',
+ 'rude',
+ 'slow',
+ 'broken',
+ 'worst',
+ 'disappointed',
+ 'noisy',
+ 'smell',
+ 'overpriced',
+ 'never',
+ 'however',
+ 'but',
+];
+
+/** Escape a keyword for RegExp; spaces become flexible whitespace. */
+function keywordPattern(keyword: string): RegExp {
+ const escaped = keyword
+ .toLowerCase()
+ .replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
+ .replace(/\s+/g, '\\s+');
+ // Word-ish boundaries so "bar" ≠ "bargain" and "ac" ≠ "vacations".
+ return new RegExp(`(?= 4) return 'positive';
- if (rating === 3) return 'mixed';
- return 'negative';
+ if (rating <= 2) return 'negative';
+ // rating === 3
+ const pos = countCueHits(reviewText, POSITIVE_CUES);
+ const neg = countCueHits(reviewText, NEGATIVE_CUES);
+ if (neg > pos + 1) return 'negative';
+ if (pos > neg + 1) return 'positive';
+ return 'mixed';
}
// ---------------------------------------------------------------------------
-// Topic extraction (keyword-based)
+// Topic extraction (keyword-based, word-boundary match)
// ---------------------------------------------------------------------------
const TOPIC_KEYWORDS: Record = {
@@ -69,15 +128,14 @@ const TOPIC_KEYWORDS: Record = {
food: ['breakfast', 'restaurant', 'food', 'dining', 'meal', 'buffet', 'coffee', 'menu'],
parking: ['parking', 'garage', 'valet', 'car'],
wifi: ['wifi', 'wi-fi', 'internet', 'connection', 'signal'],
- room_quality: ['room', 'bed', 'comfortable', 'view', 'bathroom', 'shower', 'towel', 'pillow', 'mattress', 'air conditioning', 'AC', 'heating'],
+ room_quality: ['room', 'bed', 'comfortable', 'view', 'bathroom', 'shower', 'towel', 'pillow', 'mattress', 'air conditioning', 'ac', 'heating'],
};
export function extractTopics(text: string): ReviewTopic[] {
- const lower = text.toLowerCase();
const found: ReviewTopic[] = [];
for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS) as [ReviewTopic, string[]][]) {
- if (keywords.some((kw) => lower.includes(kw))) {
+ if (keywords.some((kw) => keywordPattern(kw).test(text))) {
found.push(topic);
}
}
@@ -90,7 +148,7 @@ export function extractTopics(text: string): ReviewTopic[] {
// ---------------------------------------------------------------------------
export function analyzeReview(rating: number, reviewText: string): ReviewAnalysis {
- const sentiment = classifySentiment(rating);
+ const sentiment = classifySentiment(rating, reviewText);
const topics = extractTopics(reviewText);
const urgency = sentiment === 'negative' ? 'high' : 'normal';
@@ -228,9 +286,15 @@ export function generateResponseDraft(
}
}
- // Confidence based on topic coverage
- const topicCoverage = analysis.topics.length > 0 ? addressedTopics.length / analysis.topics.length : 1;
- const confidence = Math.min(0.95, 0.70 + topicCoverage * 0.20);
+ // Keyword hits are a weak signal — keep confidence modest so autopilot
+ // risk tiers treat these as drafts for humans, not "understood" certainty.
+ const topicCoverage =
+ analysis.topics.length > 0 ? addressedTopics.length / analysis.topics.length : 0;
+ const shortText = reviewText.trim().split(/\s+/).filter(Boolean).length < 8;
+ let confidence = 0.55 + topicCoverage * 0.2;
+ if (shortText) confidence -= 0.1;
+ if (analysis.topics.length === 0) confidence = Math.min(confidence, 0.5);
+ confidence = Math.max(0.35, Math.min(0.85, confidence));
return {
responseText: response,
diff --git a/apps/api/src/modules/import/csv.util.ts b/apps/api/src/modules/import/csv.util.ts
index 43b36ed5..9b8b0b30 100644
--- a/apps/api/src/modules/import/csv.util.ts
+++ b/apps/api/src/modules/import/csv.util.ts
@@ -4,6 +4,72 @@
* row objects keyed by the header row. Dependency-free — migration files are
* modest and this avoids pulling in a parser library.
*/
+
+/** Common PMS export headers → HAIP canonical import fields. */
+const HEADER_ALIASES: Record = {
+ 'first name': 'firstName',
+ firstname: 'firstName',
+ 'guest first name': 'firstName',
+ 'given name': 'firstName',
+ 'last name': 'lastName',
+ lastname: 'lastName',
+ surname: 'lastName',
+ 'guest last name': 'lastName',
+ 'family name': 'lastName',
+ email: 'email',
+ 'e-mail': 'email',
+ 'guest email': 'email',
+ 'guest e-mail': 'email',
+ 'email address': 'email',
+ phone: 'phone',
+ telephone: 'phone',
+ mobile: 'phone',
+ 'phone number': 'phone',
+ company: 'companyName',
+ 'company name': 'companyName',
+ loyalty: 'loyaltyNumber',
+ 'loyalty number': 'loyaltyNumber',
+ 'loyalty #': 'loyaltyNumber',
+ arrival: 'checkIn',
+ 'check in': 'checkIn',
+ 'check-in': 'checkIn',
+ departure: 'checkOut',
+ 'check out': 'checkOut',
+ 'check-out': 'checkOut',
+ 'room type': 'roomTypeId',
+ 'room type id': 'roomTypeId',
+ 'rate plan': 'ratePlanId',
+ 'rate plan id': 'ratePlanId',
+ 'room number': 'number',
+ 'room #': 'number',
+};
+
+export function normalizeHeaderKey(header: string): string {
+ return header
+ .trim()
+ .toLowerCase()
+ .replace(/[_/]+/g, ' ')
+ .replace(/-+/g, '-')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+/** Map a source CSV header to a canonical field when it matches by name or alias. */
+export function suggestCanonicalField(
+ sourceHeader: string,
+ canonicalFields: string[],
+): string | undefined {
+ const canonLower = new Map(canonicalFields.map((c) => [c.toLowerCase(), c]));
+ const exact = canonLower.get(sourceHeader.trim().toLowerCase());
+ if (exact) return exact;
+
+ const alias = HEADER_ALIASES[normalizeHeaderKey(sourceHeader)];
+ if (alias && canonLower.has(alias.toLowerCase())) {
+ return canonLower.get(alias.toLowerCase());
+ }
+ return undefined;
+}
+
export function parseCsv(text: string): Record[] {
const rows = parseRows(text);
if (rows.length === 0) return [];
@@ -71,16 +137,29 @@ function parseRows(text: string): string[][] {
* column name (as it appears in the CSV) to the importer's canonical field name.
* Columns absent from the mapping pass through unchanged so a CSV whose headers
* already match needs no mapping at all.
+ *
+ * When `canonicalFields` is provided and a source column is unmapped, common
+ * PMS header aliases are applied automatically.
*/
export function applyMapping(
row: Record,
mapping?: Record,
+ canonicalFields?: string[],
): Record {
- if (!mapping || Object.keys(mapping).length === 0) return row;
const out: Record = { ...row };
- for (const [source, canonical] of Object.entries(mapping)) {
- if (source in row) {
- out[canonical] = row[source]!;
+ if (mapping) {
+ for (const [source, canonical] of Object.entries(mapping)) {
+ if (canonical && source in row) {
+ out[canonical] = row[source]!;
+ }
+ }
+ }
+ if (canonicalFields?.length) {
+ for (const source of Object.keys(row)) {
+ const suggested = suggestCanonicalField(source, canonicalFields);
+ if (suggested && (out[suggested] === undefined || out[suggested] === '')) {
+ out[suggested] = row[source]!;
+ }
}
}
return out;
diff --git a/apps/api/src/modules/import/import.service.spec.ts b/apps/api/src/modules/import/import.service.spec.ts
index 27d22f64..ead9d2be 100644
--- a/apps/api/src/modules/import/import.service.spec.ts
+++ b/apps/api/src/modules/import/import.service.spec.ts
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { BadRequestException } from '@nestjs/common';
import { ImportService } from './import.service';
-import { parseCsv, applyMapping } from './csv.util';
+import { parseCsv, applyMapping, suggestCanonicalField } from './csv.util';
const PROP = 'aaaaaaaa-0000-4000-a000-000000000001';
@@ -28,6 +28,24 @@ describe('applyMapping', () => {
expect(mapped.firstName).toBe('Ada');
expect(mapped.lastName).toBe('Lovelace');
});
+
+ it('applies common header aliases when canonical fields are provided', () => {
+ const mapped = applyMapping(
+ { 'Guest E-mail': 'ada@x.com', 'First Name': 'Ada', Surname: 'Lovelace' },
+ undefined,
+ ['firstName', 'lastName', 'email'],
+ );
+ expect(mapped.email).toBe('ada@x.com');
+ expect(mapped.firstName).toBe('Ada');
+ expect(mapped.lastName).toBe('Lovelace');
+ });
+});
+
+describe('suggestCanonicalField', () => {
+ it('maps guest e-mail style headers to email', () => {
+ expect(suggestCanonicalField('Guest E-mail', ['firstName', 'lastName', 'email'])).toBe('email');
+ expect(suggestCanonicalField('arrival', ['checkIn', 'checkOut'])).toBe('checkIn');
+ });
});
describe('ImportService', () => {
@@ -49,10 +67,11 @@ describe('ImportService', () => {
createAutoFolio: vi.fn().mockResolvedValue({ id: 'folio-1' }),
postCharge: vi.fn().mockResolvedValue({ id: 'charge-1' }),
};
+ // Default: no existing guests (import create path). Folio tests override.
db = {
select: vi.fn(() => ({
from: vi.fn(() => ({
- where: vi.fn(() => Promise.resolve([{ id: 'res-1', propertyId: PROP, guestId: 'g-1', bookingId: 'b-1' }])),
+ where: vi.fn(() => Promise.resolve([])),
})),
})),
};
@@ -112,6 +131,43 @@ describe('ImportService', () => {
expect(guest.create).toHaveBeenCalledWith(expect.objectContaining({ firstName: 'Ada', lastName: 'Lovelace' }));
});
+ it('dedupes guests by email within the same import batch', async () => {
+ guest.create
+ .mockResolvedValueOnce({ id: 'g-1' })
+ .mockResolvedValueOnce({ id: 'g-2' });
+ const csv =
+ 'firstName,lastName,email\nAda,Lovelace,ada@x.com\nAda,Lovelace,ada@x.com';
+ const res = await svc.run(PROP, 'guests', { csv });
+ expect(guest.create).toHaveBeenCalledOnce();
+ expect(res.created).toBe(2);
+ expect(res.results[0]!.id).toBe('g-1');
+ expect(res.results[1]!.id).toBe('g-1');
+ });
+
+ it('reuses a guest already linked to the property by email', async () => {
+ db.select = vi.fn(() => ({
+ from: vi.fn(() => ({
+ where: vi.fn()
+ .mockResolvedValueOnce([{ id: 'g-existing', email: 'ada@x.com' }])
+ .mockResolvedValueOnce([{ id: 'res-1' }]),
+ })),
+ }));
+ const res = await svc.run(PROP, 'guests', {
+ rows: [{ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com' }],
+ });
+ expect(guest.create).not.toHaveBeenCalled();
+ expect(res.results[0]!.id).toBe('g-existing');
+ });
+
+ it('auto-maps Guest E-mail style headers without an explicit mapping', async () => {
+ const csv = 'First Name,Last Name,Guest E-mail\nAda,Lovelace,ada@x.com';
+ const res = await svc.run(PROP, 'guests', { csv });
+ expect(res.created).toBe(1);
+ expect(guest.create).toHaveBeenCalledWith(
+ expect.objectContaining({ firstName: 'Ada', lastName: 'Lovelace', email: 'ada@x.com' }),
+ );
+ });
+
it('imports physical rooms scoped to propertyId', async () => {
const res = await svc.run(PROP, 'rooms', {
rows: [{ roomTypeId: 'rt-1', number: '101' }],
diff --git a/apps/api/src/modules/import/import.service.ts b/apps/api/src/modules/import/import.service.ts
index 4dceacae..85a8334a 100644
--- a/apps/api/src/modules/import/import.service.ts
+++ b/apps/api/src/modules/import/import.service.ts
@@ -1,6 +1,6 @@
import { Injectable, BadRequestException, Inject } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
-import { folios, reservations } from '@telivityhaip/database';
+import { folios, guests, reservations } from '@telivityhaip/database';
import { DRIZZLE } from '../../database/database.module';
import { GuestService } from '../guest/guest.service';
import { RoomService } from '../room/room.service';
@@ -183,6 +183,8 @@ export class ImportService {
): Promise {
const imp = this.requireImporter(entity);
const dryRun = input.dryRun ?? false;
+ /** Same-batch email → guest id so duplicate CSV rows reuse one create. */
+ const guestEmailCache = new Map();
const rawRows = input.csv ? parseCsv(input.csv) : input.rows ?? [];
if (rawRows.length === 0) {
@@ -191,7 +193,7 @@ export class ImportService {
const results: RowResult[] = [];
for (let i = 0; i < rawRows.length; i++) {
- const mapped = applyMapping(rawRows[i]!, input.mapping);
+ const mapped = applyMapping(rawRows[i]!, input.mapping, imp.columns);
try {
await this.resolveLegacyRefs(mapped, propertyId, input);
// Validate required columns.
@@ -203,6 +205,9 @@ export class ImportService {
const dto = imp.build(mapped, propertyId);
if (dryRun) {
results.push({ index: i, success: true });
+ } else if (entity === 'guests') {
+ const created = await this.findOrCreateImportedGuest(propertyId, dto, guestEmailCache);
+ results.push({ index: i, success: true, id: created.id });
} else {
const created = await imp.create(dto);
results.push({ index: i, success: true, id: created.id });
@@ -222,6 +227,52 @@ export class ImportService {
};
}
+ /**
+ * Reuse a guest by email when they already have a reservation at this property
+ * (same rule as Connect/channel inbound), or when an earlier row in this batch
+ * already created them. Otherwise create via GuestService.
+ */
+ private async findOrCreateImportedGuest(
+ propertyId: string,
+ dto: {
+ firstName: string;
+ lastName: string;
+ email?: string;
+ phone?: string;
+ companyName?: string;
+ loyaltyNumber?: string;
+ },
+ emailCache: Map,
+ ): Promise<{ id: string }> {
+ const email = dto.email?.trim().toLowerCase();
+ if (email && emailCache.has(email)) {
+ return { id: emailCache.get(email)! };
+ }
+
+ if (email) {
+ const matches = await this.db.select().from(guests).where(eq(guests.email, dto.email!.trim()));
+ for (const candidate of matches) {
+ const links = await this.db
+ .select({ id: reservations.id })
+ .from(reservations)
+ .where(
+ and(eq(reservations.guestId, candidate.id), eq(reservations.propertyId, propertyId)),
+ );
+ if (links.length > 0) {
+ emailCache.set(email, candidate.id);
+ return { id: candidate.id };
+ }
+ }
+ }
+
+ const created = await this.guestService.create({
+ ...dto,
+ email: dto.email?.trim() || undefined,
+ });
+ if (email) emailCache.set(email, created.id);
+ return created;
+ }
+
private async resolveLegacyRefs(
row: Record,
propertyId: string,
diff --git a/apps/api/src/modules/llm/llm.service.spec.ts b/apps/api/src/modules/llm/llm.service.spec.ts
index 2ee78867..5e2fd740 100644
--- a/apps/api/src/modules/llm/llm.service.spec.ts
+++ b/apps/api/src/modules/llm/llm.service.spec.ts
@@ -119,4 +119,29 @@ describe('LlmService', () => {
expect(out!.rationale).toBe('ok');
expect(out!.suggestions).toEqual([]);
});
+
+ it('rejects payloads whose rationale is not a string', async () => {
+ process.env['HAIP_AI_ENABLED'] = 'true';
+ vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ message: { content: JSON.stringify({ rationale: { text: 'nope' }, suggestions: [] }) },
+ }),
+ } as any);
+ expect(await makeService().explain(INPUT)).toBeNull();
+ });
+
+ it('caps an overlong rationale', async () => {
+ process.env['HAIP_AI_ENABLED'] = 'true';
+ const long = 'x'.repeat(900);
+ vi.spyOn(globalThis, 'fetch' as any).mockResolvedValue({
+ ok: true,
+ json: async () => ({
+ message: { content: JSON.stringify({ rationale: long, suggestions: ['a'.repeat(300)] }) },
+ }),
+ } as any);
+ const out = await makeService().explain(INPUT);
+ expect(out!.rationale.length).toBe(600);
+ expect(out!.suggestions[0]!.length).toBe(200);
+ });
});
diff --git a/apps/api/src/modules/llm/llm.service.ts b/apps/api/src/modules/llm/llm.service.ts
index 9f5bf8c9..d22976a9 100644
--- a/apps/api/src/modules/llm/llm.service.ts
+++ b/apps/api/src/modules/llm/llm.service.ts
@@ -123,25 +123,36 @@ export class LlmService {
}
}
- /** Defensive JSON parse: tolerate stray prose around the JSON object. */
+ /** Defensive JSON parse: prefer whole-body JSON, then tolerate stray prose. */
private parse(content: string): { rationale: string; suggestions: string[] } | null {
+ const tryParse = (raw: string): { rationale: string; suggestions: string[] } | null => {
+ try {
+ const obj = JSON.parse(raw) as { rationale?: unknown; suggestions?: unknown };
+ if (typeof obj.rationale !== 'string') return null;
+ const rationale = obj.rationale.trim().slice(0, 600);
+ if (!rationale) return null;
+ const suggestions = Array.isArray(obj.suggestions)
+ ? obj.suggestions
+ .filter((s): s is string => typeof s === 'string' && s.trim().length > 0)
+ .map((s) => s.trim().slice(0, 200))
+ .slice(0, 3)
+ : [];
+ return { rationale, suggestions };
+ } catch {
+ return null;
+ }
+ };
+
let raw = content.trim();
+ const direct = tryParse(raw);
+ if (direct) return direct;
+
if (!raw.startsWith('{')) {
const start = raw.indexOf('{');
const end = raw.lastIndexOf('}');
if (start === -1 || end === -1 || end <= start) return null;
raw = raw.slice(start, end + 1);
}
- try {
- const obj = JSON.parse(raw) as { rationale?: unknown; suggestions?: unknown };
- const rationale = typeof obj.rationale === 'string' ? obj.rationale.trim() : '';
- if (!rationale) return null;
- const suggestions = Array.isArray(obj.suggestions)
- ? obj.suggestions.filter((s): s is string => typeof s === 'string' && s.trim().length > 0).slice(0, 3)
- : [];
- return { rationale, suggestions };
- } catch {
- return null;
- }
+ return tryParse(raw);
}
}
diff --git a/apps/dashboard/src/pages/Import.tsx b/apps/dashboard/src/pages/Import.tsx
index 22ad294e..1d91fbba 100644
--- a/apps/dashboard/src/pages/Import.tsx
+++ b/apps/dashboard/src/pages/Import.tsx
@@ -39,6 +39,61 @@ interface ImportResult {
const UNMAPPED = '';
+/** Common PMS export headers → HAIP canonical fields (keep in sync with API csv.util). */
+const HEADER_ALIASES: Record = {
+ 'first name': 'firstName',
+ firstname: 'firstName',
+ 'guest first name': 'firstName',
+ 'given name': 'firstName',
+ 'last name': 'lastName',
+ lastname: 'lastName',
+ surname: 'lastName',
+ 'guest last name': 'lastName',
+ 'family name': 'lastName',
+ email: 'email',
+ 'e-mail': 'email',
+ 'guest email': 'email',
+ 'guest e-mail': 'email',
+ 'email address': 'email',
+ phone: 'phone',
+ telephone: 'phone',
+ mobile: 'phone',
+ 'phone number': 'phone',
+ company: 'companyName',
+ 'company name': 'companyName',
+ loyalty: 'loyaltyNumber',
+ 'loyalty number': 'loyaltyNumber',
+ arrival: 'checkIn',
+ 'check in': 'checkIn',
+ 'check-in': 'checkIn',
+ departure: 'checkOut',
+ 'check out': 'checkOut',
+ 'check-out': 'checkOut',
+ 'room type': 'roomTypeId',
+ 'room type id': 'roomTypeId',
+ 'rate plan': 'ratePlanId',
+ 'rate plan id': 'ratePlanId',
+ 'room number': 'number',
+};
+
+function normalizeHeaderKey(header: string): string {
+ return header
+ .trim()
+ .toLowerCase()
+ .replace(/[_/]+/g, ' ')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+
+function suggestCanonical(sourceHeader: string, canonicalFields: string[]): string | undefined {
+ const lower = new Map(canonicalFields.map((c) => [c.toLowerCase(), c]));
+ const exact = lower.get(sourceHeader.trim().toLowerCase());
+ if (exact) return exact;
+ const alias = HEADER_ALIASES[normalizeHeaderKey(sourceHeader)];
+ if (alias && lower.has(alias.toLowerCase())) return lower.get(alias.toLowerCase());
+ return undefined;
+}
+
/** Parse a single CSV line, honoring double-quoted fields with embedded commas. */
function parseCsvLine(line: string): string[] {
const out: string[] = [];
@@ -109,13 +164,12 @@ export default function Import() {
const dataRowCount = useMemo(() => Math.max(0, splitLines(csvText).length - 1), [csvText]);
- /** Build a default mapping (exact-name match) for the current columns + entity. */
+ /** Build a default mapping (exact name or common PMS header alias). */
function buildDefaultMapping(cols: string[], ent: ImportEntity | null): Record {
const map: Record = {};
const canonical = ent?.columns ?? [];
for (const col of cols) {
- const exact = canonical.find((c) => c.toLowerCase() === col.toLowerCase());
- map[col] = exact ?? UNMAPPED;
+ map[col] = suggestCanonical(col, canonical) ?? UNMAPPED;
}
return map;
}
diff --git a/docs/test-stats.json b/docs/test-stats.json
index 8c7ade4d..3b3b1c36 100644
--- a/docs/test-stats.json
+++ b/docs/test-stats.json
@@ -1,7 +1,7 @@
{
- "tests": 2407,
+ "tests": 2418,
"files": 281,
"scope": "all workspace packages with a test script",
"semantics": "passed test cases and files containing at least one passed test; skipped test cases and skipped-only files are excluded",
- "updatedAt": "2026-09-24T06:33:10.149Z"
+ "updatedAt": "2026-09-24T06:59:00.000Z"
}