Skip to content

Commit 606b396

Browse files
committed
Switch to stored IDs for source item associations
Using the Activity.source field for associating activity with source items led to several inefficient patterns where twists and tools needed to look up an activity for its ID. The new pattern is for tools and twists to manage their own mappings, which they can do in whatever way best supports the service they're using and their access patterns. This change also replaces all contact fields with a type that supports either the contact ID or the contact details (including the email address). This also removes the need to look up contact IDs when creating and updating items.
1 parent e07c5a6 commit 606b396

File tree

30 files changed

+992
-729
lines changed

30 files changed

+992
-729
lines changed

.changeset/cute-boxes-exist.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@plotday/tool-outlook-calendar": minor
3+
"@plotday/tool-google-calendar": minor
4+
"@plotday/tool-linear": minor
5+
"@plotday/tool-asana": minor
6+
"@plotday/tool-gmail": minor
7+
"@plotday/tool-slack": minor
8+
"@plotday/tool-jira": minor
9+
"@plotday/twister": minor
10+
---
11+
12+
Changed: BREAKING: Replace Activity.source for linking with source items with generated and stored UUIDs

.changeset/frank-tigers-throw.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
"@plotday/tool-outlook-calendar": minor
3+
"@plotday/tool-google-calendar": minor
4+
"@plotday/tool-linear": minor
5+
"@plotday/tool-asana": minor
6+
"@plotday/tool-gmail": minor
7+
"@plotday/tool-slack": minor
8+
"@plotday/tool-jira": minor
9+
"@plotday/twister": minor
10+
---
11+
12+
Changed: BREAKING: Support either IDs or email for contact fields

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tools/asana/src/asana.ts

Lines changed: 22 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@ import {
55
ActivityType,
66
type NewActivityWithNotes,
77
} from "@plotday/twister";
8-
import type { Actor, ActorId, NewContact } from "@plotday/twister/plot";
98
import type {
109
Project,
1110
ProjectAuth,
1211
ProjectSyncOptions,
1312
ProjectTool,
1413
} from "@plotday/twister/common/projects";
14+
import type { NewContact } from "@plotday/twister/plot";
1515
import { Tool, type ToolBuilder } from "@plotday/twister/tool";
1616
import { type Callback, Callbacks } from "@plotday/twister/tools/callbacks";
1717
import {
@@ -150,19 +150,13 @@ export class Asana extends Tool<Asana> implements ProjectTool {
150150
* Start syncing tasks from an Asana project
151151
*/
152152
async startSync<
153-
TCallback extends (
154-
task: NewActivityWithNotes,
155-
...args: any[]
156-
) => any
153+
TCallback extends (task: NewActivityWithNotes, ...args: any[]) => any
157154
>(
158155
authToken: string,
159156
projectId: string,
160157
callback: TCallback,
161158
options?: ProjectSyncOptions,
162-
...extraArgs: TCallback extends (
163-
task: any,
164-
...rest: infer R
165-
) => any
159+
...extraArgs: TCallback extends (task: any, ...rest: infer R) => any
166160
? R
167161
: []
168162
): Promise<void> {
@@ -288,10 +282,7 @@ export class Asana extends Tool<Asana> implements ProjectTool {
288282
// Set unread based on sync type (false for initial sync to avoid notification overload)
289283
activityWithNotes.unread = !state.initialSync;
290284
// Execute the callback using the callback token
291-
await this.tools.callbacks.run(
292-
callbackToken,
293-
activityWithNotes
294-
);
285+
await this.tools.callbacks.run(callbackToken, activityWithNotes);
295286
}
296287

297288
// Check if more pages by checking if we got a full batch
@@ -329,34 +320,23 @@ export class Asana extends Tool<Asana> implements ProjectTool {
329320
const createdBy = task.created_by;
330321
const assignee = task.assignee;
331322

332-
// Create contacts for created_by and assignee
333-
const contacts: NewContact[] = [];
323+
// Prepare author and assignee contacts - will be passed directly as NewContact
324+
let authorContact: NewContact | undefined;
325+
let assigneeContact: NewContact | undefined;
326+
334327
if (createdBy?.email) {
335-
contacts.push({
328+
authorContact = {
336329
email: createdBy.email,
337330
name: createdBy.name,
338331
avatar: createdBy.photo?.image_128x128,
339-
});
332+
};
340333
}
341-
if (assignee?.email && assignee.email !== createdBy?.email) {
342-
contacts.push({
334+
if (assignee?.email) {
335+
assigneeContact = {
343336
email: assignee.email,
344337
name: assignee.name,
345338
avatar: assignee.photo?.image_128x128,
346-
});
347-
}
348-
349-
let authorActor: Actor | undefined;
350-
let assigneeActor: Actor | undefined;
351-
352-
if (contacts.length > 0) {
353-
const actors = await this.tools.plot.addContacts(contacts);
354-
if (createdBy?.email) {
355-
authorActor = actors.find((a) => a.email === createdBy.email);
356-
}
357-
if (assignee?.email) {
358-
assigneeActor = actors.find((a) => a.email === assignee.email);
359-
}
339+
};
360340
}
361341

362342
// Build notes array: description
@@ -374,9 +354,12 @@ export class Asana extends Tool<Asana> implements ProjectTool {
374354
return {
375355
type: ActivityType.Action,
376356
title: task.name,
377-
source: `asana:task:${projectId}:${task.gid}`,
378-
author: authorActor,
379-
assignee: assigneeActor,
357+
meta: {
358+
source: `asana:task:${projectId}:${task.gid}`,
359+
taskGid: task.gid,
360+
},
361+
author: authorContact,
362+
assignee: assigneeContact,
380363
doneAt: task.completed ? new Date(task.completed_at || Date.now()) : null,
381364
notes,
382365
};
@@ -392,8 +375,9 @@ export class Asana extends Tool<Asana> implements ProjectTool {
392375
authToken: string,
393376
update: import("@plotday/twister").ActivityUpdate
394377
): Promise<void> {
395-
// Extract Asana task GID from source
396-
const taskGid = update.source?.split(":").pop();
378+
// Extract Asana task GID from meta
379+
const source = update.meta?.source as string | undefined;
380+
const taskGid = source?.split(":").pop();
397381
if (!taskGid) {
398382
throw new Error("Invalid source format for Asana task");
399383
}

tools/gmail/src/gmail-api.ts

Lines changed: 27 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,9 @@
11
import { ActivityType } from "@plotday/twister";
22
import type {
3-
ActivityWithNotes,
4-
Actor,
5-
ActorId,
6-
ActorType,
7-
Note,
8-
} from "@plotday/twister";
3+
NewActivityWithNotes,
4+
NewActor,
5+
} from "@plotday/twister/plot";
6+
import { Uuid } from "@plotday/twister/utils/uuid";
97

108
export type GmailLabel = {
119
id: string;
@@ -240,35 +238,11 @@ export function parseEmailAddress(headerValue: string): EmailAddress {
240238
};
241239
}
242240

243-
/**
244-
* Converts an EmailAddress to an Actor.
245-
*/
246-
function emailAddressToActor(emailAddress: EmailAddress): Actor {
247-
return {
248-
id: `contact:${emailAddress.email}` as ActorId,
249-
type: 2 as ActorType, // ActorType.Contact
250-
email: emailAddress.email,
251-
name: emailAddress.name,
252-
};
253-
}
254-
255-
/**
256-
* Parses multiple email addresses from a header value (comma-separated).
257-
*/
258-
function parseEmailAddresses(headerValue: string | null): Actor[] {
259-
if (!headerValue) return [];
260-
261-
return headerValue
262-
.split(",")
263-
.map((addr) => addr.trim())
264-
.filter((addr) => addr.length > 0)
265-
.map((addr) => emailAddressToActor(parseEmailAddress(addr)));
266-
}
267241

268242
/**
269-
* Parses email addresses and returns just the ActorIds for mentions.
243+
* Parses email addresses and returns NewActor[] for mentions.
270244
*/
271-
function parseEmailAddressIds(headerValue: string | null): ActorId[] {
245+
function parseEmailAddressesToNewActors(headerValue: string | null): NewActor[] {
272246
if (!headerValue) return [];
273247

274248
return headerValue
@@ -277,7 +251,10 @@ function parseEmailAddressIds(headerValue: string | null): ActorId[] {
277251
.filter((addr) => addr.length > 0)
278252
.map((addr) => {
279253
const parsed = parseEmailAddress(addr);
280-
return `contact:${parsed.email}` as ActorId;
254+
return {
255+
email: parsed.email,
256+
name: parsed.name || undefined,
257+
} as NewActor;
281258
});
282259
}
283260

@@ -367,36 +344,15 @@ function extractAttachments(
367344
}
368345

369346
/**
370-
* Transforms a Gmail thread into an ActivityWithNotes structure.
347+
* Transforms a Gmail thread into a NewActivityWithNotes structure.
371348
* The subject becomes the Activity title, and each email becomes a Note.
372349
*/
373-
export function transformGmailThread(thread: GmailThread): ActivityWithNotes {
350+
export function transformGmailThread(thread: GmailThread): NewActivityWithNotes {
374351
if (!thread.messages || thread.messages.length === 0) {
375352
// Return empty structure for invalid threads
376353
return {
377-
id: `gmail:${thread.id}` as any,
378354
type: ActivityType.Note,
379-
author: { id: "system" as ActorId, type: 1 as ActorType, name: null },
380-
title: null,
381-
assignee: null,
382-
doneAt: null,
383-
start: null,
384-
end: null,
385-
recurrenceUntil: null,
386-
recurrenceCount: null,
387-
priority: null as any,
388-
recurrenceRule: null,
389-
recurrenceExdates: null,
390-
recurrenceDates: null,
391-
recurrence: null,
392-
occurrence: null,
393-
source: null,
394-
meta: null,
395-
mentions: null,
396-
tags: null,
397-
draft: false,
398-
private: false,
399-
archived: false,
355+
title: "",
400356
notes: [],
401357
};
402358
}
@@ -405,33 +361,15 @@ export function transformGmailThread(thread: GmailThread): ActivityWithNotes {
405361
const subject = getHeader(parentMessage, "Subject");
406362

407363
// Create Activity
408-
const activity: ActivityWithNotes = {
409-
id: `gmail:${thread.id}` as any,
364+
const activity: NewActivityWithNotes = {
410365
type: ActivityType.Note,
411-
author: { id: "system" as ActorId, type: 1 as ActorType, name: null },
412366
title: subject || "Email",
413-
assignee: null,
414-
doneAt: null,
415367
start: new Date(parseInt(parentMessage.internalDate)),
416-
end: null,
417-
recurrenceUntil: null,
418-
recurrenceCount: null,
419-
priority: null as any,
420-
recurrenceRule: null,
421-
recurrenceExdates: null,
422-
recurrenceDates: null,
423-
recurrence: null,
424-
occurrence: null,
425-
source: `gmail:${thread.id}`,
426368
meta: {
427369
threadId: thread.id,
428370
historyId: thread.historyId,
371+
source: `gmail:${thread.id}`,
429372
},
430-
mentions: null,
431-
tags: null,
432-
draft: false,
433-
private: false,
434-
archived: false,
435373
notes: [],
436374
};
437375

@@ -446,23 +384,21 @@ export function transformGmailThread(thread: GmailThread): ActivityWithNotes {
446384

447385
const body = extractBody(message.payload);
448386

449-
// Combine to and cc for mentions
450-
const mentions: ActorId[] = [
451-
...parseEmailAddressIds(to),
452-
...parseEmailAddressIds(cc),
387+
// Combine to and cc for mentions - convert to NewActor[]
388+
const mentions: NewActor[] = [
389+
...parseEmailAddressesToNewActors(to),
390+
...parseEmailAddressesToNewActors(cc),
453391
];
454392

455-
const note: Note = {
456-
id: `gmail:${thread.id}:${message.id}` as any,
457-
activity: activity,
458-
author: emailAddressToActor(sender),
393+
// Create NewNote (Omit<NewNote, "activity">)
394+
const note = {
395+
id: Uuid.Generate(),
396+
author: {
397+
email: sender.email,
398+
name: sender.name || undefined,
399+
} as NewActor,
459400
content: body || message.snippet,
460-
links: null,
461-
mentions: mentions.length > 0 ? mentions : null,
462-
tags: null,
463-
draft: false,
464-
private: false,
465-
archived: false,
401+
mentions: mentions.length > 0 ? mentions : undefined,
466402
};
467403

468404
activity.notes.push(note);

0 commit comments

Comments
 (0)