246 lines
8.6 KiB
TypeScript
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]!;
|
|
}
|