@agent-native/core 0.161.20 → 0.161.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/corpus/README.md +1 -1
- package/corpus/templates/clips/actions/finalize-meeting.ts +57 -21
- package/corpus/templates/clips/actions/get-meeting.ts +11 -3
- package/corpus/templates/clips/actions/request-transcript.ts +183 -8
- package/corpus/templates/clips/actions/update-meeting.ts +66 -14
- package/corpus/templates/clips/app/components/meetings/canvas-editor.tsx +80 -135
- package/corpus/templates/clips/app/i18n/en-US.ts +4 -0
- package/corpus/templates/clips/app/routes/_app.meetings.$meetingId.tsx +470 -35
- package/corpus/templates/clips/changelog/2026-08-18-meeting-notes-support-rich-editing-for-personal-notes-summar.md +6 -0
- package/corpus/templates/clips/desktop/src-tauri/src/whisper_speech.rs +13 -3
- package/corpus/templates/mail/app/components/email/EmailList.tsx +74 -22
- package/corpus/templates/mail/app/components/email/infinite-pagination.ts +82 -0
- package/corpus/templates/mail/app/hooks/use-emails.ts +1 -0
- package/corpus/templates/mail/app/pages/InboxPage.tsx +15 -10
- package/corpus/templates/mail/app/pages/inbox-zero.ts +35 -0
- package/corpus/templates/mail/changelog/2026-08-18-pinned-labels-load-matches.md +6 -0
- package/corpus/templates/slides/actions/get-deck.ts +5 -1
- package/corpus/templates/slides/actions/list-decks.ts +29 -8
- package/corpus/templates/slides/actions/view-screen.ts +7 -3
- package/corpus/templates/slides/changelog/2026-08-18-decks-appear-under-mine.md +6 -0
- package/corpus/templates/slides/shared/ownership.ts +6 -0
- package/dist/app-config/schema.d.ts +2 -0
- package/dist/app-config/workspace.d.ts +2 -0
- package/dist/app-config/workspace.js +18 -0
- package/dist/catalog.json +23 -23
- package/dist/client/analytics.d.ts +1 -0
- package/dist/client/api-path.js +7 -3
- package/dist/client/frame.js +20 -6
- package/dist/client/org/workspace-app-links.js +10 -1
- package/dist/collab/awareness.d.ts +2 -2
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/deploy/build.js +26 -2
- package/dist/file-upload/actions/upload-image.d.ts +1 -1
- package/dist/notifications/routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +6 -6
- package/dist/progress/routes.d.ts +1 -1
- package/dist/provider-api/actions/custom-provider-registration.d.ts +14 -14
- package/dist/provider-api/actions/provider-api.d.ts +15 -15
- package/dist/secrets/routes.d.ts +9 -9
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/app-origin-config.d.ts +1 -0
- package/dist/server/app-origin-config.js +3 -0
- package/dist/server/auth.js +2 -2
- package/dist/server/cookie-namespace.js +17 -2
- package/dist/server/credential-provider.js +5 -2
- package/dist/server/derived-secret.js +3 -2
- package/dist/server/onboarding-html.js +34 -2
- package/dist/server/workspace-oauth.js +11 -6
- package/dist/shared/runtime-config.d.ts +2 -0
- package/dist/shared/runtime-config.js +12 -3
- package/dist/vite/client.js +5 -2
- package/package.json +2 -2
package/corpus/README.md
CHANGED
|
@@ -95,6 +95,7 @@ export default defineAction({
|
|
|
95
95
|
// stale (see PENDING_STALE_MS). A fresh 'pending' row may still be an
|
|
96
96
|
// active text-model run, so it is left alone.
|
|
97
97
|
const staleBefore = new Date(Date.now() - PENDING_STALE_MS).toISOString();
|
|
98
|
+
const claimUpdatedAt = nowIso;
|
|
98
99
|
const claimPredicate = args.force
|
|
99
100
|
? or(
|
|
100
101
|
inArray(schema.meetings.transcriptStatus, ["ready", "failed"]),
|
|
@@ -241,10 +242,13 @@ export default defineAction({
|
|
|
241
242
|
|
|
242
243
|
// Single transaction so the meetings write and the action-items
|
|
243
244
|
// delete+insert can't interleave with a second concurrent finalize call.
|
|
244
|
-
// The meetings update is CAS-guarded on transcriptStatus='pending'
|
|
245
|
-
// call's
|
|
246
|
-
// that
|
|
245
|
+
// The meetings update is CAS-guarded on both transcriptStatus='pending'
|
|
246
|
+
// and the timestamp written by this call's claim. If a manual edit landed
|
|
247
|
+
// after the claim, finish the AI fields but preserve that edit's action
|
|
248
|
+
// items instead of replacing them with stale model output.
|
|
249
|
+
let persistedActionItems = actionItems;
|
|
247
250
|
await db.transaction(async (tx) => {
|
|
251
|
+
const finalNow = new Date().toISOString();
|
|
248
252
|
const written = await tx
|
|
249
253
|
.update(schema.meetings)
|
|
250
254
|
.set({
|
|
@@ -252,32 +256,64 @@ export default defineAction({
|
|
|
252
256
|
summaryMd,
|
|
253
257
|
bulletsJson: JSON.stringify(bullets),
|
|
254
258
|
actionItemsJson: JSON.stringify(actionItems),
|
|
255
|
-
updatedAt:
|
|
259
|
+
updatedAt: finalNow,
|
|
256
260
|
})
|
|
257
261
|
.where(
|
|
258
262
|
and(
|
|
259
263
|
eq(schema.meetings.id, args.meetingId),
|
|
260
264
|
eq(schema.meetings.transcriptStatus, "pending"),
|
|
265
|
+
eq(schema.meetings.updatedAt, claimUpdatedAt),
|
|
261
266
|
),
|
|
262
267
|
)
|
|
263
268
|
.returning({ id: schema.meetings.id });
|
|
264
|
-
if (
|
|
269
|
+
if (written.length) {
|
|
270
|
+
// Replace the per-row action items so the dedicated table mirrors the
|
|
271
|
+
// JSON column. The meeting-row CAS above serializes this replacement
|
|
272
|
+
// with update-meeting's own meeting-row transaction.
|
|
273
|
+
await tx
|
|
274
|
+
.delete(schema.meetingActionItems)
|
|
275
|
+
.where(eq(schema.meetingActionItems.meetingId, args.meetingId));
|
|
276
|
+
if (actionItems.length) {
|
|
277
|
+
await tx.insert(schema.meetingActionItems).values(
|
|
278
|
+
actionItems.map((item) => ({
|
|
279
|
+
id: nanoid(),
|
|
280
|
+
meetingId: args.meetingId,
|
|
281
|
+
assigneeEmail: item.assigneeEmail ?? null,
|
|
282
|
+
text: item.text,
|
|
283
|
+
dueDate: item.dueDate ?? null,
|
|
284
|
+
completedAt: null,
|
|
285
|
+
})),
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
265
290
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
291
|
+
const [preserved] = await tx
|
|
292
|
+
.update(schema.meetings)
|
|
293
|
+
.set({
|
|
294
|
+
transcriptStatus: "ready",
|
|
295
|
+
summaryMd,
|
|
296
|
+
bulletsJson: JSON.stringify(bullets),
|
|
297
|
+
updatedAt: finalNow,
|
|
298
|
+
})
|
|
299
|
+
.where(
|
|
300
|
+
and(
|
|
301
|
+
eq(schema.meetings.id, args.meetingId),
|
|
302
|
+
eq(schema.meetings.transcriptStatus, "pending"),
|
|
303
|
+
),
|
|
304
|
+
)
|
|
305
|
+
.returning({ actionItemsJson: schema.meetings.actionItemsJson });
|
|
306
|
+
if (!preserved?.actionItemsJson) return;
|
|
307
|
+
|
|
308
|
+
try {
|
|
309
|
+
const currentActionItems = JSON.parse(preserved.actionItemsJson);
|
|
310
|
+
if (Array.isArray(currentActionItems)) {
|
|
311
|
+
persistedActionItems = currentActionItems;
|
|
312
|
+
}
|
|
313
|
+
} catch (error) {
|
|
314
|
+
console.warn(
|
|
315
|
+
"[finalize-meeting] preserved action items JSON was malformed; keeping dedicated rows",
|
|
316
|
+
error,
|
|
281
317
|
);
|
|
282
318
|
}
|
|
283
319
|
});
|
|
@@ -288,7 +324,7 @@ export default defineAction({
|
|
|
288
324
|
meetingId: args.meetingId,
|
|
289
325
|
summaryMd,
|
|
290
326
|
bullets,
|
|
291
|
-
actionItems,
|
|
327
|
+
actionItems: persistedActionItems,
|
|
292
328
|
provider: result.provider,
|
|
293
329
|
};
|
|
294
330
|
},
|
|
@@ -17,9 +17,11 @@ interface Bullet {
|
|
|
17
17
|
text: string;
|
|
18
18
|
}
|
|
19
19
|
interface ActionItem {
|
|
20
|
-
|
|
20
|
+
id?: string;
|
|
21
|
+
assigneeEmail?: string | null;
|
|
21
22
|
text: string;
|
|
22
|
-
dueDate?: string;
|
|
23
|
+
dueDate?: string | null;
|
|
24
|
+
completedAt?: string | null;
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/**
|
|
@@ -100,10 +102,16 @@ export default defineAction({
|
|
|
100
102
|
.from(schema.meetingParticipants)
|
|
101
103
|
.where(eq(schema.meetingParticipants.meetingId, meetingId));
|
|
102
104
|
|
|
103
|
-
const
|
|
105
|
+
const actionItemRows = await db
|
|
104
106
|
.select()
|
|
105
107
|
.from(schema.meetingActionItems)
|
|
106
108
|
.where(eq(schema.meetingActionItems.meetingId, meetingId));
|
|
109
|
+
// Older meetings may have action items only in the JSON column. Prefer
|
|
110
|
+
// the dedicated rows once present, but keep those legacy meetings
|
|
111
|
+
// editable instead of returning an empty action-item list.
|
|
112
|
+
const actionItems = actionItemRows.length
|
|
113
|
+
? actionItemRows
|
|
114
|
+
: actionItemsParsed;
|
|
107
115
|
|
|
108
116
|
let recording = null;
|
|
109
117
|
let transcript = null;
|
|
@@ -63,6 +63,7 @@ import {
|
|
|
63
63
|
buildCaptionSegmentsFromText,
|
|
64
64
|
normalizeTranscriptSegments,
|
|
65
65
|
parseTranscriptSegments,
|
|
66
|
+
type TranscriptSegment,
|
|
66
67
|
} from "../shared/transcript-segments.js";
|
|
67
68
|
import { PENDING_TRANSCRIPT_HEARTBEAT_MS } from "../shared/transcript-status.js";
|
|
68
69
|
import cleanupTranscript from "./cleanup-transcript.js";
|
|
@@ -498,6 +499,161 @@ function fullTextSegmentJson(
|
|
|
498
499
|
return JSON.stringify(buildCaptionSegmentsFromText(text, durationMs));
|
|
499
500
|
}
|
|
500
501
|
|
|
502
|
+
type WordSegmenterConstructor = new (
|
|
503
|
+
locale?: string | string[],
|
|
504
|
+
options?: { granularity: "word" },
|
|
505
|
+
) => {
|
|
506
|
+
segment: (input: string) => Iterable<{ segment: string }>;
|
|
507
|
+
};
|
|
508
|
+
|
|
509
|
+
function splitMeasuredText(text: string): {
|
|
510
|
+
units: string[];
|
|
511
|
+
separator: "" | " ";
|
|
512
|
+
} {
|
|
513
|
+
const normalized = text.replace(/\s+/g, " ").trim();
|
|
514
|
+
if (!normalized) return { units: [], separator: " " };
|
|
515
|
+
if (/\s/.test(text)) {
|
|
516
|
+
return {
|
|
517
|
+
units: normalized.split(" ").filter(Boolean),
|
|
518
|
+
separator: " ",
|
|
519
|
+
};
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// CJK and other scripts often have no spaces between words. Segment those
|
|
523
|
+
// with the runtime's locale data when available, then fall back to Unicode
|
|
524
|
+
// code points so every timed cue can still receive some cleaned text.
|
|
525
|
+
const Segmenter = (
|
|
526
|
+
Intl as typeof Intl & { Segmenter?: WordSegmenterConstructor }
|
|
527
|
+
).Segmenter;
|
|
528
|
+
if (Segmenter) {
|
|
529
|
+
const segmented = Array.from(
|
|
530
|
+
new Segmenter(undefined, { granularity: "word" }).segment(normalized),
|
|
531
|
+
({ segment }) => segment,
|
|
532
|
+
);
|
|
533
|
+
if (segmented.length > 1) {
|
|
534
|
+
return { units: segmented, separator: "" };
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
return { units: Array.from(normalized), separator: "" };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function normalizeAlignmentText(text: string): string {
|
|
542
|
+
return text
|
|
543
|
+
.normalize("NFKC")
|
|
544
|
+
.toLowerCase()
|
|
545
|
+
.replace(/[^\p{L}\p{N}]+/gu, "");
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function hasMeasuredAttribution(segments: TranscriptSegment[]): boolean {
|
|
549
|
+
return segments.some(
|
|
550
|
+
(segment) => segment.source !== undefined || segment.speaker !== undefined,
|
|
551
|
+
);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* Rewrite attributed cues only when the cleanup is a sequence-preserving
|
|
556
|
+
* normalization. Without a per-word speaker map, proportional redistribution
|
|
557
|
+
* can move words across a speaker boundary. Returning null tells the caller to
|
|
558
|
+
* keep the complete original transcript instead of storing mismatched fullText
|
|
559
|
+
* and segmentsJson.
|
|
560
|
+
*/
|
|
561
|
+
function rewriteAttributedSegmentText(
|
|
562
|
+
segments: TranscriptSegment[],
|
|
563
|
+
cleanedText: string,
|
|
564
|
+
): TranscriptSegment[] | null {
|
|
565
|
+
const originalAlignment = normalizeAlignmentText(
|
|
566
|
+
segments.map((segment) => segment.text).join(" "),
|
|
567
|
+
);
|
|
568
|
+
const cleanedAlignment = normalizeAlignmentText(cleanedText);
|
|
569
|
+
if (!originalAlignment || originalAlignment !== cleanedAlignment) return null;
|
|
570
|
+
|
|
571
|
+
const cleanedChars = Array.from(cleanedText);
|
|
572
|
+
let charIndex = 0;
|
|
573
|
+
const rewritten: TranscriptSegment[] = [];
|
|
574
|
+
|
|
575
|
+
for (const segment of segments) {
|
|
576
|
+
const target = normalizeAlignmentText(segment.text);
|
|
577
|
+
if (!target) return null;
|
|
578
|
+
const startIndex = charIndex;
|
|
579
|
+
let normalized = "";
|
|
580
|
+
|
|
581
|
+
while (
|
|
582
|
+
charIndex < cleanedChars.length &&
|
|
583
|
+
normalized.length < target.length
|
|
584
|
+
) {
|
|
585
|
+
normalized += normalizeAlignmentText(cleanedChars[charIndex]);
|
|
586
|
+
if (!target.startsWith(normalized)) return null;
|
|
587
|
+
charIndex += 1;
|
|
588
|
+
}
|
|
589
|
+
if (normalized !== target) return null;
|
|
590
|
+
|
|
591
|
+
// Keep punctuation and whitespace with the preceding cue. They do not
|
|
592
|
+
// affect alignment, but dropping them makes the displayed transcript look
|
|
593
|
+
// broken at every measured speaker boundary.
|
|
594
|
+
while (
|
|
595
|
+
charIndex < cleanedChars.length &&
|
|
596
|
+
!normalizeAlignmentText(cleanedChars[charIndex])
|
|
597
|
+
) {
|
|
598
|
+
charIndex += 1;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const text = cleanedChars.slice(startIndex, charIndex).join("").trim();
|
|
602
|
+
if (!text) return null;
|
|
603
|
+
rewritten.push({ ...segment, text });
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
if (normalizeAlignmentText(cleanedChars.slice(charIndex).join(""))) {
|
|
607
|
+
return null;
|
|
608
|
+
}
|
|
609
|
+
return rewritten;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function rewriteMeasuredSegmentText(
|
|
613
|
+
segments: TranscriptSegment[],
|
|
614
|
+
cleanedText: string,
|
|
615
|
+
): TranscriptSegment[] | null {
|
|
616
|
+
const cleaned = splitMeasuredText(cleanedText);
|
|
617
|
+
if (segments.length === 0 || cleaned.units.length === 0) return [];
|
|
618
|
+
|
|
619
|
+
if (hasMeasuredAttribution(segments)) {
|
|
620
|
+
return rewriteAttributedSegmentText(segments, cleanedText);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
const weights = segments.map((segment) =>
|
|
624
|
+
Math.max(1, splitMeasuredText(segment.text).units.length),
|
|
625
|
+
);
|
|
626
|
+
const totalWeight = weights.reduce((sum, weight) => sum + weight, 0);
|
|
627
|
+
let unitIndex = 0;
|
|
628
|
+
let weightIndex = 0;
|
|
629
|
+
|
|
630
|
+
return segments.flatMap((segment, index) => {
|
|
631
|
+
const isLast = index === segments.length - 1;
|
|
632
|
+
const remainingSegments = segments.length - index - 1;
|
|
633
|
+
const targetEnd = isLast
|
|
634
|
+
? cleaned.units.length
|
|
635
|
+
: Math.round(
|
|
636
|
+
(cleaned.units.length * (weightIndex + weights[index])) / totalWeight,
|
|
637
|
+
);
|
|
638
|
+
// Unattributed cues can safely be dropped when cleanup removes short
|
|
639
|
+
// filler. Keep the measured timings for the remaining cues and preserve
|
|
640
|
+
// the complete cleaned text instead of reverting to stale source text.
|
|
641
|
+
const minimumEnd =
|
|
642
|
+
cleaned.units.length >= segments.length ? unitIndex + 1 : unitIndex;
|
|
643
|
+
const maximumEnd = Math.max(
|
|
644
|
+
unitIndex,
|
|
645
|
+
cleaned.units.length - remainingSegments,
|
|
646
|
+
);
|
|
647
|
+
const end = isLast
|
|
648
|
+
? cleaned.units.length
|
|
649
|
+
: Math.min(maximumEnd, Math.max(minimumEnd, targetEnd));
|
|
650
|
+
const text = cleaned.units.slice(unitIndex, end).join(cleaned.separator);
|
|
651
|
+
unitIndex = end;
|
|
652
|
+
weightIndex += weights[index];
|
|
653
|
+
return text ? [{ ...segment, text }] : [];
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
|
|
501
657
|
/**
|
|
502
658
|
* Pick the segments to store after cleanup rewrites the transcript text.
|
|
503
659
|
*
|
|
@@ -511,9 +667,17 @@ export function resolveCleanupSegmentsJson(
|
|
|
511
667
|
priorSegmentsJson: string | null | undefined,
|
|
512
668
|
cleanedText: string,
|
|
513
669
|
durationMs: number | null | undefined,
|
|
514
|
-
): string {
|
|
515
|
-
|
|
516
|
-
|
|
670
|
+
): string | null {
|
|
671
|
+
const priorSegments = parseTranscriptSegments(priorSegmentsJson);
|
|
672
|
+
if (priorSegments.length > 0) {
|
|
673
|
+
const rewrittenSegments = rewriteMeasuredSegmentText(
|
|
674
|
+
priorSegments,
|
|
675
|
+
cleanedText,
|
|
676
|
+
);
|
|
677
|
+
if (rewrittenSegments === null) return null;
|
|
678
|
+
if (rewrittenSegments.length > 0) {
|
|
679
|
+
return JSON.stringify(rewrittenSegments);
|
|
680
|
+
}
|
|
517
681
|
}
|
|
518
682
|
return fullTextSegmentJson(cleanedText, durationMs);
|
|
519
683
|
}
|
|
@@ -785,6 +949,21 @@ async function cleanupNativeTranscript({
|
|
|
785
949
|
return { cleaned: false, provider: result.provider };
|
|
786
950
|
}
|
|
787
951
|
|
|
952
|
+
const cleanupSegmentsJson = resolveCleanupSegmentsJson(
|
|
953
|
+
segmentsJson,
|
|
954
|
+
cleanedText,
|
|
955
|
+
durationMs,
|
|
956
|
+
);
|
|
957
|
+
if (!cleanupSegmentsJson) {
|
|
958
|
+
await writeTranscriptCleanupState(recordingId, {
|
|
959
|
+
status: "unchanged",
|
|
960
|
+
provider: result.provider,
|
|
961
|
+
failureReason:
|
|
962
|
+
"Cleanup output could not be aligned with measured speaker cues; the original transcript was kept.",
|
|
963
|
+
});
|
|
964
|
+
return { cleaned: false, provider: result.provider };
|
|
965
|
+
}
|
|
966
|
+
|
|
788
967
|
const now = new Date().toISOString();
|
|
789
968
|
const language = await resolveStoredLanguage(db, recordingId);
|
|
790
969
|
const updated = await db
|
|
@@ -794,11 +973,7 @@ async function cleanupNativeTranscript({
|
|
|
794
973
|
status: "ready",
|
|
795
974
|
failureReason: null,
|
|
796
975
|
language,
|
|
797
|
-
segmentsJson:
|
|
798
|
-
segmentsJson,
|
|
799
|
-
cleanedText,
|
|
800
|
-
durationMs,
|
|
801
|
-
),
|
|
976
|
+
segmentsJson: cleanupSegmentsJson,
|
|
802
977
|
fullText: cleanedText,
|
|
803
978
|
retryCount: 0,
|
|
804
979
|
updatedAt: now,
|
|
@@ -1,21 +1,27 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Update meeting
|
|
2
|
+
* Update a meeting's metadata. Editor access required.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { defineAction } from "@agent-native/core";
|
|
6
6
|
import { writeAppState } from "@agent-native/core/application-state";
|
|
7
7
|
import { assertAccess } from "@agent-native/core/sharing";
|
|
8
|
-
import { eq } from "drizzle-orm";
|
|
8
|
+
import { and, eq } from "drizzle-orm";
|
|
9
9
|
import { z } from "zod";
|
|
10
10
|
|
|
11
11
|
import { getDb, schema } from "../server/db/index.js";
|
|
12
|
+
import { nanoid } from "../server/lib/recordings.js";
|
|
12
13
|
import { booleanParam } from "./lib/cli-params.js";
|
|
13
14
|
|
|
14
15
|
export default defineAction({
|
|
15
16
|
description:
|
|
16
|
-
"Partially update a meeting
|
|
17
|
+
"Partially update a meeting (title, schedule, notes, summary, action items). Only provided fields are updated.",
|
|
17
18
|
schema: z.object({
|
|
18
19
|
id: z.string().describe("Meeting id"),
|
|
20
|
+
expectedUpdatedAt: z
|
|
21
|
+
.string()
|
|
22
|
+
.min(1)
|
|
23
|
+
.optional()
|
|
24
|
+
.describe("Reject the write if the meeting changed since it was read"),
|
|
19
25
|
title: z.string().optional(),
|
|
20
26
|
scheduledStart: z.string().nullish(),
|
|
21
27
|
scheduledEnd: z.string().nullish(),
|
|
@@ -34,13 +40,15 @@ export default defineAction({
|
|
|
34
40
|
actionItems: z
|
|
35
41
|
.array(
|
|
36
42
|
z.object({
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
id: z.string().optional(),
|
|
44
|
+
assigneeEmail: z.string().email().nullable().optional(),
|
|
45
|
+
text: z.string().trim().min(1),
|
|
46
|
+
dueDate: z.string().nullable().optional(),
|
|
47
|
+
completedAt: z.string().nullable().optional(),
|
|
40
48
|
}),
|
|
41
49
|
)
|
|
42
50
|
.optional()
|
|
43
|
-
.describe("Replace the action item set on the
|
|
51
|
+
.describe("Replace the action item set on the meeting"),
|
|
44
52
|
transcriptStatus: z.enum(["idle", "pending", "ready", "failed"]).optional(),
|
|
45
53
|
shareTranscript: booleanParam
|
|
46
54
|
.optional()
|
|
@@ -72,17 +80,61 @@ export default defineAction({
|
|
|
72
80
|
patch.userNotesMd = args.userNotesMd;
|
|
73
81
|
if (typeof args.summaryMd === "string") patch.summaryMd = args.summaryMd;
|
|
74
82
|
if (args.bullets) patch.bulletsJson = JSON.stringify(args.bullets);
|
|
75
|
-
|
|
76
|
-
|
|
83
|
+
const normalizedActionItems = args.actionItems?.map((item) => ({
|
|
84
|
+
id: item.id?.trim() || nanoid(),
|
|
85
|
+
assigneeEmail: item.assigneeEmail ?? null,
|
|
86
|
+
text: item.text.trim(),
|
|
87
|
+
dueDate: item.dueDate ?? null,
|
|
88
|
+
completedAt: item.completedAt ?? null,
|
|
89
|
+
}));
|
|
90
|
+
if (normalizedActionItems !== undefined)
|
|
91
|
+
patch.actionItemsJson = JSON.stringify(normalizedActionItems);
|
|
77
92
|
if (args.transcriptStatus) patch.transcriptStatus = args.transcriptStatus;
|
|
78
93
|
if (args.shareTranscript !== undefined)
|
|
79
94
|
patch.shareTranscript = args.shareTranscript;
|
|
80
95
|
if (args.visibility) patch.visibility = args.visibility;
|
|
81
96
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
97
|
+
// Keep the denormalized JSON used by the summary and the dedicated action
|
|
98
|
+
// item rows used by list/query surfaces in sync as one mutation. In
|
|
99
|
+
// particular, completion state and manual edits must survive a refresh.
|
|
100
|
+
await db.transaction(async (tx) => {
|
|
101
|
+
const [updatedMeeting] = await tx
|
|
102
|
+
.update(schema.meetings)
|
|
103
|
+
.set(patch)
|
|
104
|
+
.where(
|
|
105
|
+
args.expectedUpdatedAt !== undefined
|
|
106
|
+
? and(
|
|
107
|
+
eq(schema.meetings.id, args.id),
|
|
108
|
+
eq(schema.meetings.updatedAt, args.expectedUpdatedAt),
|
|
109
|
+
)
|
|
110
|
+
: eq(schema.meetings.id, args.id),
|
|
111
|
+
)
|
|
112
|
+
.returning({
|
|
113
|
+
id: schema.meetings.id,
|
|
114
|
+
updatedAt: schema.meetings.updatedAt,
|
|
115
|
+
});
|
|
116
|
+
if (!updatedMeeting) {
|
|
117
|
+
throw new Error("Meeting changed while saving. Refresh and try again.");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (normalizedActionItems !== undefined) {
|
|
121
|
+
await tx
|
|
122
|
+
.delete(schema.meetingActionItems)
|
|
123
|
+
.where(eq(schema.meetingActionItems.meetingId, args.id));
|
|
124
|
+
if (normalizedActionItems.length > 0) {
|
|
125
|
+
await tx.insert(schema.meetingActionItems).values(
|
|
126
|
+
normalizedActionItems.map((item) => ({
|
|
127
|
+
id: item.id,
|
|
128
|
+
meetingId: args.id,
|
|
129
|
+
assigneeEmail: item.assigneeEmail,
|
|
130
|
+
text: item.text,
|
|
131
|
+
dueDate: item.dueDate,
|
|
132
|
+
completedAt: item.completedAt,
|
|
133
|
+
})),
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
});
|
|
86
138
|
|
|
87
139
|
await writeAppState("refresh-signal", { ts: Date.now() });
|
|
88
140
|
|
|
@@ -92,6 +144,6 @@ export default defineAction({
|
|
|
92
144
|
.where(eq(schema.meetings.id, args.id))
|
|
93
145
|
.limit(1);
|
|
94
146
|
|
|
95
|
-
return { meeting };
|
|
147
|
+
return { meeting, actionItems: normalizedActionItems };
|
|
96
148
|
},
|
|
97
149
|
});
|