Files
suaveplan/packages/commerce/contacts/api-plugin-contacts/src/repositories/contact-repository.ts
2026-05-27 23:29:53 +01:00

246 lines
8.6 KiB
TypeScript

/**
* SurrealDB-backed implementation of the genesis `@suaveplan/contacts`
* `ContactStore` interface. Wraps a connected `SurrealAdapter` and maps
* row shapes onto the canonical `Contact` entity (parsing ISO date
* strings into `Date` instances). No mutation of the adapter lifecycle
* — connect / disconnect is the host's responsibility.
*/
import type {
Contact,
ContactChannel,
ContactListResult,
ContactQuery,
ContactStatus,
ContactStore,
CreateContactInput,
UpdateContactInput,
} from "@suaveplan/contacts";
import type { SurrealAdapter } from "@suaveplan/surrealdb";
import {
CONTACTS_API_CODES,
ContactsApiError,
} from "../error-codes/error-codes.js";
interface ContactRow {
readonly id: unknown;
readonly tenantId: string;
readonly displayName: string;
readonly firstName: string | null;
readonly lastName: string | null;
readonly channels: readonly ContactChannel[];
readonly tags: readonly string[];
readonly status: ContactStatus;
readonly metadata: Record<string, unknown>;
readonly createdAt: string | Date;
readonly updatedAt: string | Date;
}
/** SurrealDB-backed contact repository implementing `ContactStore`. */
export class SurrealContactRepository implements ContactStore {
public constructor(private readonly db: SurrealAdapter) {}
public async create(input: CreateContactInput): Promise<Contact> {
try {
// Build the CONTENT object with optional fields omitted when
// unset — SurrealDB's `option<string>` rejects an explicit `null`.
const content: Record<string, unknown> = {
tenantId: input.tenantId,
displayName: input.displayName,
channels: input.channels,
tags: input.tags ?? [],
status: input.status ?? "active",
metadata: input.metadata ?? {},
};
if (input.firstName !== undefined)
content["firstName"] = input.firstName;
if (input.lastName !== undefined)
content["lastName"] = input.lastName;
const result = await this.db.query<ContactRow>(
"CREATE contact CONTENT $p0",
[content]
);
return this.mapContact(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"failed to create contact",
{ cause: cause as Error }
);
}
}
public async getById(id: string): Promise<Contact | null> {
const recordId = this.toRecordId(id);
const result = await this.db.query<ContactRow>(
"SELECT * FROM type::record('contact', $p0)",
[recordId]
);
if (result.length === 0) return null;
return this.mapContact(result[0]!);
}
public async update(
id: string,
input: UpdateContactInput
): Promise<Contact> {
try {
const recordId = this.toRecordId(id);
const result = await this.db.query<ContactRow>(
"UPDATE type::record('contact', $p0) MERGE $p1",
[recordId, input]
);
return this.mapContact(pickFirst(result));
} catch (cause) {
if (cause instanceof ContactsApiError) throw cause;
throw new ContactsApiError(
CONTACTS_API_CODES.UPDATE_FAILED,
`failed to update contact ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async delete(id: string): Promise<void> {
try {
const recordId = this.toRecordId(id);
await this.db.query("DELETE type::record('contact', $p0)", [
recordId,
]);
} catch (cause) {
throw new ContactsApiError(
CONTACTS_API_CODES.DELETE_FAILED,
`failed to delete contact ${id}`,
{ cause: cause as Error, context: { id } }
);
}
}
public async list(query: ContactQuery): Promise<ContactListResult> {
const params: unknown[] = [query.tenantId];
let where = "tenantId = $p0";
if (query.status !== undefined) {
params.push(query.status);
where += ` AND status = $p${params.length - 1}`;
}
if (query.channelType !== undefined) {
params.push(query.channelType);
where += ` AND channels[WHERE type = $p${params.length - 1}].value != NONE`;
}
if (query.search !== undefined) {
params.push(query.search);
const idx = params.length - 1;
where +=
` AND (string::contains(displayName, $p${idx})` +
` OR string::contains(firstName, $p${idx})` +
` OR string::contains(lastName, $p${idx}))`;
}
const limit = query.limit ?? 50;
const offset = query.offset ?? 0;
params.push(limit);
const limitIdx = params.length - 1;
params.push(offset);
const offsetIdx = params.length - 1;
const countResult = await this.db.query<{ count: number }>(
`SELECT count() AS count FROM contact WHERE ${where} GROUP ALL`,
params
);
const total = countResult[0]?.count ?? 0;
const rows = await this.db.query<ContactRow>(
`SELECT * FROM contact WHERE ${where} ORDER BY createdAt DESC LIMIT $p${limitIdx} START $p${offsetIdx}`,
params
);
return {
contacts: rows.map((r) => this.mapContact(r)),
total,
};
}
public async findByChannelValue(
tenantId: string,
channelType: string,
channelValue: string
): Promise<Contact | null> {
const result = await this.db.query<ContactRow>(
`SELECT * FROM contact
WHERE tenantId = $p0
AND channels[WHERE type = $p1 AND value = $p2] != NONE
LIMIT 1`,
[tenantId, channelType, channelValue]
);
if (result.length === 0) return null;
return this.mapContact(result[0]!);
}
public async findByIds(ids: readonly string[]): Promise<Contact[]> {
if (ids.length === 0) return [];
// SurrealDB v3 `type::record` accepts table + id tail; we resolve each id
// individually via parallel queries so the contract works with bare
// ids ("abc") and full record-strings ("contact:abc") alike.
const results = await Promise.all(
ids.map((id) =>
this.db.query<ContactRow>(
"SELECT * FROM type::record('contact', $p0)",
[this.toRecordId(id)]
)
)
);
return results.flat().map((r) => this.mapContact(r));
}
public async countByTenant(tenantId: string): Promise<number> {
const result = await this.db.query<{ count: number }>(
"SELECT count() AS count FROM contact WHERE tenantId = $p0 GROUP ALL",
[tenantId]
);
return result[0]?.count ?? 0;
}
private toRecordId(id: string): string {
// type::record('contact', $tail) — strip any leading "contact:" prefix
// so callers can pass either form.
const idx = id.indexOf(":");
return idx === -1 ? id : id.slice(idx + 1);
}
private mapContact(row: ContactRow): Contact {
const contact: Contact = {
id: String(row.id),
tenantId: row.tenantId,
displayName: row.displayName,
...(row.firstName !== null && row.firstName !== undefined
? { firstName: row.firstName }
: {}),
...(row.lastName !== null && row.lastName !== undefined
? { lastName: row.lastName }
: {}),
channels: row.channels,
tags: row.tags,
status: row.status,
metadata: row.metadata,
createdAt:
row.createdAt instanceof Date
? row.createdAt
: new Date(row.createdAt),
updatedAt:
row.updatedAt instanceof Date
? row.updatedAt
: new Date(row.updatedAt),
};
return contact;
}
}
function pickFirst<T>(rows: readonly T[] | null | undefined): T {
if (!rows || rows.length === 0) {
throw new ContactsApiError(
CONTACTS_API_CODES.CREATE_FAILED,
"SurrealDB returned no row for write operation"
);
}
return rows[0]!;
}