diff --git a/packages/cli/src/resources/todo.ts b/packages/cli/src/resources/todo.ts index aedf3ad..c54a112 100644 --- a/packages/cli/src/resources/todo.ts +++ b/packages/cli/src/resources/todo.ts @@ -43,6 +43,85 @@ const CALENDAR_SPACE = 'calendar:space:Calendar' as Ref const TODO_PRIORITIES = new Set(['High', 'Medium', 'Low', 'NoPriority', 'Urgent']) const TODO_VISIBILITIES = new Set(['public', 'busy', 'private']) +const CALENDAR_CLASS = 'calendar:class:Calendar' as Ref> +const EXTERNAL_CALENDAR_CLASS = 'calendar:class:ExternalCalendar' as Ref> +const PRIMARY_CALENDAR_PREF = 'calendar:class:PrimaryCalendar' as Ref> + +/** + * True when the SDK error indicates the queried class doesn't exist in the + * workspace model (`Hierarchy` throws `Error('domain not found: ')`). + * Used to distinguish "this workspace doesn't model Employee/Person/Calendar" + * from real network/auth failures, which must surface to the caller. + */ +function isDomainNotFound (err: unknown): boolean { + // Hierarchy throws `Error('domain not found: ')` when the queried + // class isn't in the workspace model. Match case-insensitively so a + // future SDK message tweak (casing, translation) doesn't silently break + // the discriminator. + return err instanceof Error && /domain not found/i.test(err.message) +} + +/** + * Resolve the user's PersonalCalendar the same way the web UI's + * `findPrimaryCalendar` / `getPrimaryCalendar` does + * (plugins/time-resources/src/utils.ts, plugins/calendar/src/utils.ts). + * Mirrors the platform's selection order: + * 1. PrimaryCalendar preference's `attachedTo` (the user-picked Calendar). + * 2. First ExternalCalendar with `default: true` and `hidden: false`. + * 3. Synthetic `${accountUuid}_calendar` (matches the platform's fallback + * so the WorkSlot lands in the same calendar the UI would use). + * + * The shared `getPrimaryCalendar` helper from `@hcengineering/calendar` + * is not reused because the CLI does not depend on that package; the + * logic is small enough to inline and the platform contract is the source + * of truth. + */ +async function resolvePrimaryCalendar( + client: Awaited>, + primarySocialId: string, + accountUuid: string +): Promise> { + let calendars: Array; _class?: Ref>; user?: string; hidden?: boolean; access?: string; default?: boolean }> + try { + calendars = (await client.findAll(CALENDAR_CLASS, { + user: primarySocialId, + hidden: false, + access: { $in: ['owner', 'writer'] } + })) as typeof calendars + } catch (err) { + // "domain not found" means this workspace doesn't model Calendar — fall + // through to the synthetic default so the slot still lands somewhere + // visible. Any other error (network, auth, server) must propagate. + if (!isDomainNotFound(err)) throw err + return `${accountUuid}_calendar` as Ref + } + + // 1. PrimaryCalendar preference names the chosen Calendar via attachedTo. + // The platform UI queries with an empty filter (Preference is treated as a + // workspace singleton there), so we match that contract instead of + // guessing a user-scoped filter. + try { + const pref = (await client.findOne(PRIMARY_CALENDAR_PREF, {})) as (Doc & { attachedTo?: Ref }) | undefined + if (pref?.attachedTo !== undefined) { + const match = calendars.find((c) => c._id === pref.attachedTo) + if (match !== undefined) return match._id + } + } catch (err) { + if (!isDomainNotFound(err)) throw err + // preference class not in this workspace — fall through to ExternalCalendar scan + } + + // 2. Eligible ExternalCalendar default. + for (const c of calendars) { + if (c._class === EXTERNAL_CALENDAR_CLASS && !c.hidden && c.default === true) { + return c._id + } + } + + // 3. Synthetic account-default Calendar — matches what getPrimaryCalendar + // returns so the WorkSlot lands in the same calendar the UI would use. + return `${accountUuid}_calendar` as Ref +} function parseDate(value: string, field: string): number { const t = new Date(value).getTime() @@ -64,16 +143,22 @@ async function readBodyText(opts: { body?: string; bodyFile?: string }): Promise /** * Resolves a workspace user reference for an email address or the current account. * + * Returns both the doc `_id` and the class it belongs to. Callers that build + * `attachedTo` / `attachedToClass` pairs (e.g. `addCollection`) MUST use the + * returned class — ToDo `user` accepts either Employee or Person refs depending + * on the workspace model, and a mismatch between ref and class will be + * rejected by the server or land the todo in the wrong collection. + * * @param email - The person to resolve * @param resolveOpts - Optional `--url` / `--workspace` to thread through to the account-service fallback - * @returns The matching person or employee reference, or the current account UUID when `email` is omitted + * @returns The matching `_id` paired with its class, or the current account UUID with `contact:class:Person` when `email` is omitted * @throws {CliError} When no matching person is found in the workspace */ async function resolveEmployeeId( client: Awaited>, email?: string, resolveOpts: ResolveOpts = {} -): Promise> { +): Promise<{ ref: Ref, class: Ref> }> { if (email) { // Todo `user` accepts either an Employee or a Person ref depending on // the workspace model, so try Employee first then Person via the shared @@ -85,33 +170,66 @@ async function resolveEmployeeId( ['contact:class:Employee', 'contact:class:Person'], resolveOpts ) - if (id !== undefined) return id + if (id !== undefined) { + // The helper returns only the _id, not the class. Probe to find + // which class it belongs to so `attachedToClass` matches the ref. + for (const classId of ['contact:class:Employee', 'contact:class:Person']) { + try { + const doc = await client.findOne(classId as Ref>, { _id: id }) + if (doc) return { ref: id, class: classId as Ref> } + } catch (err) { + if (!isDomainNotFound(err)) throw err + // class not in this workspace's model; try the next one + } + } + } } // Workspace-local fallback for name-based lookups or when the // cross-workspace lookup doesn't match anything in this workspace. // Scan both Employee and Person so users who exist only as Employee - // are still matched by name. + // are still matched by name. Track the class each candidate came from + // so callers can mirror it into `attachedToClass`. const lower = email.toLowerCase() - const candidates: Array = [] for (const classId of ['contact:class:Employee', 'contact:class:Person']) { try { const docs = (await client.findAll( classId as Ref>, {}, { limit: 500 } )) as Array - candidates.push(...docs) - } catch { + const hit = docs.find( + (p) => (p.name ?? '').toLowerCase() === lower || (p.email ?? '').toLowerCase() === lower + ) + if (hit) return { ref: hit._id, class: classId as Ref> } + } catch (err) { + if (!isDomainNotFound(err)) throw err // class not in this workspace's model; try the next one } } - const hit = candidates.find( - (p) => (p.name ?? '').toLowerCase() === lower || (p.email ?? '').toLowerCase() === lower - ) - if (!hit) throw new CliError(ExitCode.NotFound, `no person matching ${email} in this workspace`) - return hit._id + throw new CliError(ExitCode.NotFound, `no person matching ${email} in this workspace`) } - // Default: current user + // Default: current user. Look up the workspace-local Person/Employee linked + // to the current account by `personUuid` — account.uuid is the + // account-level UUID and may not be a valid Person/Employee doc _id in + // this workspace. Probe both classes, matching the email branch above. + // Throws if neither class is provisioned (the bootstrap step should + // create one). const account = await client.getAccount() - return account.uuid as Ref + for (const classId of ['contact:class:Employee', 'contact:class:Person']) { + try { + const doc = (await client.findOne( + classId as Ref>, + { personUuid: account.uuid } + )) as Doc | undefined + if (doc !== undefined) return { ref: doc._id, class: classId as Ref> } + } catch (err) { + if (!isDomainNotFound(err)) throw err + // class not in this workspace's model; try the next one + } + } + throw new CliError( + ExitCode.NotFound, + `no contact:class:Person or contact:class:Employee provisioned for current account`, + 'open the workspace once in the browser, or re-run without omitting --owner' + ) } // ---- list ---- @@ -137,7 +255,7 @@ export async function listActions(opts: ListActionsOpts = {}): Promise { const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { const query: Record = {} - if (opts.owner) query.user = await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace }) + if (opts.owner) query.user = (await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace })).ref if (opts.priority) { if (!TODO_PRIORITIES.has(opts.priority)) { throw new CliError(ExitCode.Validation, `invalid --priority: ${opts.priority}`, `expected one of ${[...TODO_PRIORITIES].join(' | ')}`) @@ -275,7 +393,7 @@ export async function createAction(opts: CreateActionOpts): Promise { : (opts.description ? opts.description : '') const client = await connectCli({ url: opts.url, workspace: opts.workspace }) try { - const user = await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace }) + const { ref: user, class: userClass } = await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace }) if (opts.priority && !TODO_PRIORITIES.has(opts.priority)) { throw new CliError(ExitCode.Validation, `invalid --priority: ${opts.priority}`, `expected one of ${[...TODO_PRIORITIES].join(' | ')}`) } @@ -295,7 +413,7 @@ export async function createAction(opts: CreateActionOpts): Promise { attachedToClass = opts.attachedToClass as Ref> } else { attachedTo = user - attachedToClass = 'contact:class:Person' as Ref> + attachedToClass = userClass } const data: Record = { @@ -373,7 +491,7 @@ export async function updateAction(ref: string, opts: UpdateActionOpts): Promise } ops.visibility = opts.visibility } - if (opts.owner) ops.user = await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace }) + if (opts.owner) ops.user = (await resolveEmployeeId(client, opts.owner, { url: opts.url, workspace: opts.workspace })).ref if (Object.keys(ops).length === 0) { throw new CliError(ExitCode.Validation, 'nothing to update', 'pass --title, --description, --due, --priority, --visibility, or --owner') @@ -526,12 +644,19 @@ export async function scheduleAction(ref: string, opts: ScheduleActionOpts): Pro const account = await client.getAccount() const startMs = parseDate(opts.start, '--start') const dueMs = startMs + opts.duration * 60 * 1000 + // Resolve the user's PersonalCalendar the same way the web UI does + // (see time-resources/utils.ts: findPrimaryCalendar). `todo.user` is an + // Employee ref — using it as the `calendar` field makes the WorkSlot + // invisible to the Schedule Calendar UI, which filters by Calendar ref. + const calendarRef = account.primarySocialId !== undefined + ? await resolvePrimaryCalendar(client, account.primarySocialId, account.uuid) + : (`${account.uuid}_calendar` as Ref) const data: Record = { title: todo.title, date: startMs, dueDate: dueMs, allDay: !!opts.allDay, - calendar: todo.user, + calendar: calendarRef, eventId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`, access: 'owner', visibility: todo.visibility ?? 'public',