Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 144 additions & 19 deletions packages/cli/src/resources/todo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,85 @@ const CALENDAR_SPACE = 'calendar:space:Calendar' as Ref<Space>

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<Class<Doc>>
const EXTERNAL_CALENDAR_CLASS = 'calendar:class:ExternalCalendar' as Ref<Class<Doc>>
const PRIMARY_CALENDAR_PREF = 'calendar:class:PrimaryCalendar' as Ref<Class<Doc>>

/**
* True when the SDK error indicates the queried class doesn't exist in the
* workspace model (`Hierarchy` throws `Error('domain not found: <class>')`).
* 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: <class>')` 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<ReturnType<typeof connectCli>>,
primarySocialId: string,
accountUuid: string
): Promise<Ref<Doc>> {
let calendars: Array<Doc & { _id: Ref<Doc>; _class?: Ref<Class<Doc>>; 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<Doc>
}

// 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<Doc> }) | undefined
Comment thread
IamCoder18 marked this conversation as resolved.
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) {
Comment thread
IamCoder18 marked this conversation as resolved.
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<Doc>
}

function parseDate(value: string, field: string): number {
const t = new Date(value).getTime()
Expand All @@ -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<ReturnType<typeof connectCli>>,
email?: string,
resolveOpts: ResolveOpts = {}
): Promise<Ref<Doc>> {
): Promise<{ ref: Ref<Doc>, class: Ref<Class<Doc>> }> {
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
Expand All @@ -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<Class<Doc>>, { _id: id })
if (doc) return { ref: id, class: classId as Ref<Class<Doc>> }
} 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<Doc & { name?: string; email?: string }> = []
for (const classId of ['contact:class:Employee', 'contact:class:Person']) {
try {
const docs = (await client.findAll(
classId as Ref<Class<Doc>>, {}, { limit: 500 }
Comment thread
IamCoder18 marked this conversation as resolved.
)) as Array<Doc & { name?: string; email?: string }>
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<Class<Doc>> }
} 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<Doc>
for (const classId of ['contact:class:Employee', 'contact:class:Person']) {
try {
const doc = (await client.findOne(
classId as Ref<Class<Doc>>,
{ personUuid: account.uuid }
)) as Doc | undefined
if (doc !== undefined) return { ref: doc._id, class: classId as Ref<Class<Doc>> }
} 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 ----
Expand All @@ -137,7 +255,7 @@ export async function listActions(opts: ListActionsOpts = {}): Promise<void> {
const client = await connectCli({ url: opts.url, workspace: opts.workspace })
try {
const query: Record<string, unknown> = {}
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(' | ')}`)
Expand Down Expand Up @@ -275,7 +393,7 @@ export async function createAction(opts: CreateActionOpts): Promise<void> {
: (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(' | ')}`)
}
Expand All @@ -295,7 +413,7 @@ export async function createAction(opts: CreateActionOpts): Promise<void> {
attachedToClass = opts.attachedToClass as Ref<Class<Doc>>
} else {
attachedTo = user
attachedToClass = 'contact:class:Person' as Ref<Class<Doc>>
attachedToClass = userClass
}

const data: Record<string, unknown> = {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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<Doc>)
const data: Record<string, unknown> = {
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',
Expand Down