@agent-native/core 0.128.1 → 0.128.2
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/core/CHANGELOG.md +6 -0
- package/corpus/core/package.json +2 -1
- package/corpus/core/src/server/email-template.ts +33 -0
- package/corpus/templates/chat/_gitignore +2 -0
- package/corpus/templates/clips/AGENTS.md +6 -1
- package/corpus/templates/clips/actions/complete-transactional-email-summary.ts +56 -0
- package/corpus/templates/clips/actions/import-loom-recording.ts +258 -76
- package/corpus/templates/clips/actions/lib/direct-video.ts +96 -0
- package/corpus/templates/clips/actions/lib/loom-import-job.ts +53 -2
- package/corpus/templates/clips/actions/lib/loom-video.ts +2 -62
- package/corpus/templates/clips/actions/lib/video-download-limits.ts +62 -0
- package/corpus/templates/clips/actions/list-transactional-email-ai-requests.ts +249 -0
- package/corpus/templates/clips/app/components/library/library-grid.tsx +65 -0
- package/corpus/templates/clips/app/components/library/library-layout.tsx +55 -0
- package/corpus/templates/clips/app/components/recorder/pre-record-panel.tsx +28 -126
- package/corpus/templates/clips/app/global.css +23 -0
- package/corpus/templates/clips/app/hooks/use-transactional-email-bridge.ts +98 -0
- package/corpus/templates/clips/app/i18n/en-US.ts +20 -0
- package/corpus/templates/clips/app/routes/_app.tsx +2 -0
- package/corpus/templates/clips/app/routes/import.tsx +396 -0
- package/corpus/templates/clips/app/routes/r.$recordingId.tsx +8 -6
- package/corpus/templates/clips/app/routes/record.tsx +13 -71
- package/corpus/templates/clips/changelog/2026-07-25-two-clip-emails-now-include-a-concise-summary-generated-secu.md +6 -0
- package/corpus/templates/clips/data/clips-transactional-emails/config.json +3 -0
- package/corpus/templates/clips/server/jobs/transactional-emails.ts +1034 -0
- package/corpus/templates/clips/server/lib/transactional-email-store.ts +865 -0
- package/corpus/templates/clips/server/lib/transactional-email-templates.ts +270 -0
- package/corpus/templates/clips/server/plugins/calendar-jobs.ts +2 -0
- package/corpus/templates/clips/server/routes/api/view-event.post.ts +59 -2
- package/corpus/templates/content/.agents/skills/document-editing/references/databases.md +8 -2
- package/corpus/templates/content/AGENTS.md +8 -0
- package/corpus/templates/content/actions/_content-database-personal-view.ts +28 -3
- package/corpus/templates/content/actions/_property-utils.ts +32 -2
- package/corpus/templates/content/actions/configure-document-property.ts +12 -2
- package/corpus/templates/content/actions/delete-document-property.ts +13 -3
- package/corpus/templates/content/actions/duplicate-document-property.ts +13 -3
- package/corpus/templates/content/actions/list-content-spaces.ts +5 -2
- package/corpus/templates/content/actions/list-document-properties.ts +12 -3
- package/corpus/templates/content/actions/move-database-item.ts +74 -55
- package/corpus/templates/content/actions/reorder-document-property.ts +20 -4
- package/corpus/templates/content/actions/set-document-property.ts +10 -1
- package/corpus/templates/content/actions/update-content-database-personal-view.ts +7 -0
- package/corpus/templates/content/app/components/editor/DocumentBlockFields.tsx +31 -21
- package/corpus/templates/content/app/components/editor/DocumentEditor.tsx +30 -3
- package/corpus/templates/content/app/components/editor/DocumentInfoPanel.tsx +8 -1
- package/corpus/templates/content/app/components/editor/DocumentProperties.tsx +72 -20
- package/corpus/templates/content/app/components/editor/database/DatabaseView.tsx +79 -10
- package/corpus/templates/content/app/components/editor/database/TimelineView.tsx +8 -1
- package/corpus/templates/content/app/components/editor/database/settings.tsx +6 -0
- package/corpus/templates/content/app/components/editor/database/sidebar.tsx +246 -26
- package/corpus/templates/content/app/components/sidebar/DocumentSidebar.tsx +491 -197
- package/corpus/templates/content/app/components/sidebar/DocumentTreeItem.tsx +3 -3
- package/corpus/templates/content/app/components/sidebar/sidebar-reorder.tsx +409 -0
- package/corpus/templates/content/app/hooks/use-content-database.ts +117 -3
- package/corpus/templates/content/app/hooks/use-content-spaces.ts +1 -0
- package/corpus/templates/content/app/hooks/use-document-properties.ts +62 -18
- package/corpus/templates/content/app/hooks/use-documents.ts +13 -5
- package/corpus/templates/content/app/i18n-data.ts +261 -3
- package/corpus/templates/content/app/routes/_app.page.$id.tsx +9 -2
- package/corpus/templates/content/changelog/2026-07-24-database-page-properties-keep-their-context.md +6 -0
- package/corpus/templates/content/changelog/2026-07-26-sidebar-references-can-now-be-personally-reordered.md +6 -0
- package/corpus/templates/content/docs/solutions/2026-07-24-database-property-context-switch-diagnosis.md +249 -0
- package/corpus/templates/content/docs/solutions/content-sidebar-order-shape.md +389 -0
- package/corpus/templates/content/shared/api.ts +28 -0
- package/corpus/toolkit/CHANGELOG.md +6 -0
- package/corpus/toolkit/package.json +1 -1
- package/corpus/toolkit/src/editor/DragHandle.ts +63 -9
- package/dist/collab/struct-routes.d.ts +1 -1
- package/dist/observability/routes.d.ts +3 -3
- package/dist/server/agent-engine-api-key-route.d.ts +1 -1
- package/dist/server/email-template.d.ts +7 -0
- package/dist/server/email-template.d.ts.map +1 -1
- package/dist/server/email-template.js +24 -0
- package/dist/server/email-template.js.map +1 -1
- package/dist/server/transcribe-voice.d.ts +1 -1
- package/dist/templates/chat/_gitignore +2 -0
- package/package.json +4 -3
- package/src/server/email-template.ts +33 -0
- package/src/templates/chat/_gitignore +2 -0
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
emailStrong,
|
|
3
|
+
getAppProductionUrl,
|
|
4
|
+
renderEmail,
|
|
5
|
+
sendEmail,
|
|
6
|
+
} from "@agent-native/core/server";
|
|
7
|
+
|
|
8
|
+
const CLIPS_BRAND_NAME = "Clips";
|
|
9
|
+
const EMAIL_SEND_TIMEOUT_MS = 60_000;
|
|
10
|
+
const FRIENDLY_REPLY_TO = "hello@agent-native.com";
|
|
11
|
+
const UNTITLED_CLIP = "Untitled Clip";
|
|
12
|
+
|
|
13
|
+
interface TransactionalEmailBase {
|
|
14
|
+
to: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type ClipsTransactionalEmailInput =
|
|
18
|
+
| (TransactionalEmailBase & {
|
|
19
|
+
kind: "first-view";
|
|
20
|
+
recordingId: string;
|
|
21
|
+
title?: string | null;
|
|
22
|
+
viewerEmail?: string | null;
|
|
23
|
+
})
|
|
24
|
+
| (TransactionalEmailBase & {
|
|
25
|
+
kind: "unviewed-reminder";
|
|
26
|
+
recordingId: string;
|
|
27
|
+
title?: string | null;
|
|
28
|
+
senderEmail?: string | null;
|
|
29
|
+
senderName?: string | null;
|
|
30
|
+
brandLogoUrl?: string | null;
|
|
31
|
+
})
|
|
32
|
+
| (TransactionalEmailBase & {
|
|
33
|
+
kind: "first-import";
|
|
34
|
+
recordingId: string;
|
|
35
|
+
title?: string | null;
|
|
36
|
+
})
|
|
37
|
+
| (TransactionalEmailBase & {
|
|
38
|
+
kind: "two-clips";
|
|
39
|
+
generatedSummary?: string | null;
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export interface ClipsTransactionalEmailRenderOptions {
|
|
43
|
+
appUrl: string;
|
|
44
|
+
appBasePath?: string;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface RenderedClipsTransactionalEmail {
|
|
48
|
+
subject: string;
|
|
49
|
+
html: string;
|
|
50
|
+
text: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function singleLine(value: string | null | undefined): string {
|
|
54
|
+
return (value ?? "").replace(/[\r\n]+/g, " ").trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clipTitle(value: string | null | undefined): string {
|
|
58
|
+
return singleLine(value) || UNTITLED_CLIP;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function normalizeEmailDisplayName(
|
|
62
|
+
value: string | null | undefined,
|
|
63
|
+
fallback: string,
|
|
64
|
+
): string {
|
|
65
|
+
const email = singleLine(value);
|
|
66
|
+
const match = /^([A-Za-z]+(?:[._-][A-Za-z]+)*)@[^\s@]+$/.exec(email);
|
|
67
|
+
if (!match) return email || fallback;
|
|
68
|
+
|
|
69
|
+
return match[1]
|
|
70
|
+
.split(/[._-]/)
|
|
71
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
72
|
+
.join(" ");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function normalizeBasePath(value: string | undefined): string {
|
|
76
|
+
const normalized = singleLine(value).replace(/^\/+/, "").replace(/\/+$/, "");
|
|
77
|
+
return normalized ? `/${normalized}` : "";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function appUrlForPath(
|
|
81
|
+
path: string,
|
|
82
|
+
{ appUrl, appBasePath }: ClipsTransactionalEmailRenderOptions,
|
|
83
|
+
): string {
|
|
84
|
+
const url = new URL(appUrl);
|
|
85
|
+
let basePath = normalizeBasePath(url.pathname);
|
|
86
|
+
const configuredBasePath = normalizeBasePath(appBasePath);
|
|
87
|
+
|
|
88
|
+
if (
|
|
89
|
+
configuredBasePath &&
|
|
90
|
+
basePath !== configuredBasePath &&
|
|
91
|
+
!basePath.endsWith(configuredBasePath)
|
|
92
|
+
) {
|
|
93
|
+
basePath = `${basePath}${configuredBasePath}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return new URL(`${basePath}${path}`, url.origin).toString();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function clipUrl(
|
|
100
|
+
recordingId: string,
|
|
101
|
+
options: ClipsTransactionalEmailRenderOptions,
|
|
102
|
+
): string {
|
|
103
|
+
return appUrlForPath(`/r/${encodeURIComponent(recordingId)}`, options);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function recordUrl(options: ClipsTransactionalEmailRenderOptions): string {
|
|
107
|
+
return appUrlForPath("/record", options);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function resolveBrandLogoUrl(
|
|
111
|
+
value: string | null | undefined,
|
|
112
|
+
options: ClipsTransactionalEmailRenderOptions,
|
|
113
|
+
): string | undefined {
|
|
114
|
+
const candidate = singleLine(value);
|
|
115
|
+
if (!candidate) return undefined;
|
|
116
|
+
try {
|
|
117
|
+
const url = new URL(candidate, options.appUrl);
|
|
118
|
+
return url.protocol === "https:" || url.protocol === "http:"
|
|
119
|
+
? url.toString()
|
|
120
|
+
: undefined;
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function validReplyTo(value: string | null | undefined): string | undefined {
|
|
127
|
+
const candidate = singleLine(value).toLowerCase();
|
|
128
|
+
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(candidate) ? candidate : undefined;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export function renderClipsTransactionalEmail(
|
|
132
|
+
input: ClipsTransactionalEmailInput,
|
|
133
|
+
options: ClipsTransactionalEmailRenderOptions,
|
|
134
|
+
): RenderedClipsTransactionalEmail {
|
|
135
|
+
const title = input.kind === "two-clips" ? undefined : clipTitle(input.title);
|
|
136
|
+
|
|
137
|
+
switch (input.kind) {
|
|
138
|
+
case "first-view": {
|
|
139
|
+
const viewer = normalizeEmailDisplayName(input.viewerEmail, "Someone");
|
|
140
|
+
const subject = `Your Clip “${title}” got its first view`;
|
|
141
|
+
const rendered = renderEmail({
|
|
142
|
+
brandName: CLIPS_BRAND_NAME,
|
|
143
|
+
preheader: subject,
|
|
144
|
+
heading: "Someone watched your Clip",
|
|
145
|
+
paragraphs: [
|
|
146
|
+
`${emailStrong(viewer)} registered the first view of ${emailStrong(title!)}.`,
|
|
147
|
+
"Clips tracks advanced analytics on your viewers' activity, and can even tell you whether your recipient took AI actions with your link. Come back to Clips to view analytics, or configure Clips AI to take agentic actions on your behalf.",
|
|
148
|
+
],
|
|
149
|
+
cta: {
|
|
150
|
+
label: "See Clip activity",
|
|
151
|
+
url: clipUrl(input.recordingId, options),
|
|
152
|
+
},
|
|
153
|
+
footer:
|
|
154
|
+
"You received this one-time note because this Clip got its first registered view.",
|
|
155
|
+
});
|
|
156
|
+
return { subject, ...rendered };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
case "unviewed-reminder": {
|
|
160
|
+
const sender =
|
|
161
|
+
singleLine(input.senderName) ||
|
|
162
|
+
normalizeEmailDisplayName(input.senderEmail, "Someone");
|
|
163
|
+
const subject = `Still need to watch “${title}”?`;
|
|
164
|
+
const url = clipUrl(input.recordingId, options);
|
|
165
|
+
const rendered = renderEmail({
|
|
166
|
+
brandName: CLIPS_BRAND_NAME,
|
|
167
|
+
brandLogoUrl: resolveBrandLogoUrl(input.brandLogoUrl, options),
|
|
168
|
+
preheader: subject,
|
|
169
|
+
heading: `${sender} shared a Clip with you`,
|
|
170
|
+
paragraphs: [
|
|
171
|
+
`${emailStrong(title!)} is waiting whenever you have a moment.`,
|
|
172
|
+
],
|
|
173
|
+
linkBlock: {
|
|
174
|
+
intro:
|
|
175
|
+
"Don't have a moment to spare? Share the below link with your own AI agent and ask it for a summary:",
|
|
176
|
+
url,
|
|
177
|
+
},
|
|
178
|
+
cta: {
|
|
179
|
+
label: "Watch the Clip Manually",
|
|
180
|
+
url,
|
|
181
|
+
},
|
|
182
|
+
footer: `You received this reminder because ${sender} shared this Clip with you two days ago.`,
|
|
183
|
+
});
|
|
184
|
+
return { subject, ...rendered };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case "first-import": {
|
|
188
|
+
const subject = "Your first imported video is now Agent-Native";
|
|
189
|
+
const url = clipUrl(input.recordingId, options);
|
|
190
|
+
const rendered = renderEmail({
|
|
191
|
+
brandName: CLIPS_BRAND_NAME,
|
|
192
|
+
preheader: subject,
|
|
193
|
+
heading: "Your video is ready for more than playback",
|
|
194
|
+
paragraphs: [
|
|
195
|
+
`${emailStrong(title!)} is now an Agent-Native Clip.`,
|
|
196
|
+
"Its speech and on-screen visuals are available as agent-readable context for summaries, exact-moment lookup, tickets, emails, and follow-up work.",
|
|
197
|
+
],
|
|
198
|
+
cta: {
|
|
199
|
+
label: "Open your Agent-Native Clip",
|
|
200
|
+
url,
|
|
201
|
+
},
|
|
202
|
+
linkBlock: {
|
|
203
|
+
intro: "Or just feed this link to your own AI agent:",
|
|
204
|
+
url,
|
|
205
|
+
placement: "after-cta",
|
|
206
|
+
},
|
|
207
|
+
footer:
|
|
208
|
+
"You received this one-time note because your first imported video is ready.",
|
|
209
|
+
});
|
|
210
|
+
return { subject, ...rendered };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case "two-clips": {
|
|
214
|
+
const summary =
|
|
215
|
+
singleLine(input.generatedSummary) ||
|
|
216
|
+
"Two people shared Clips with you, giving you a quick look at what Agent-Native video can do.";
|
|
217
|
+
const subject = "You've received two Clips. What would you create?";
|
|
218
|
+
const rendered = renderEmail({
|
|
219
|
+
brandName: CLIPS_BRAND_NAME,
|
|
220
|
+
preheader: subject,
|
|
221
|
+
heading: "You’ve received two Agent-Native Clips",
|
|
222
|
+
paragraphs: [
|
|
223
|
+
emailStrong(summary),
|
|
224
|
+
"Clips are screen recordings that are friendly for both human viewing and AI agent use. What would you create with yours?",
|
|
225
|
+
],
|
|
226
|
+
cta: {
|
|
227
|
+
label: "Record an Agent-Native Clip",
|
|
228
|
+
url: recordUrl(options),
|
|
229
|
+
},
|
|
230
|
+
footer:
|
|
231
|
+
"This one-time note was sent after two Clips were shared with you.",
|
|
232
|
+
});
|
|
233
|
+
return { subject, ...rendered };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export async function sendClipsTransactionalEmail(
|
|
239
|
+
input: ClipsTransactionalEmailInput,
|
|
240
|
+
): Promise<void> {
|
|
241
|
+
const rendered = renderClipsTransactionalEmail(input, {
|
|
242
|
+
appUrl: getAppProductionUrl(),
|
|
243
|
+
appBasePath: process.env.VITE_APP_BASE_PATH || process.env.APP_BASE_PATH,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
const reminderSender =
|
|
247
|
+
input.kind === "unviewed-reminder"
|
|
248
|
+
? singleLine(input.senderName) ||
|
|
249
|
+
normalizeEmailDisplayName(input.senderEmail, "Someone")
|
|
250
|
+
: undefined;
|
|
251
|
+
|
|
252
|
+
await sendEmail({
|
|
253
|
+
to: input.to,
|
|
254
|
+
subject: rendered.subject,
|
|
255
|
+
html: rendered.html,
|
|
256
|
+
text: rendered.text,
|
|
257
|
+
fromName: reminderSender
|
|
258
|
+
? `${reminderSender} (via Agent-Native Clips)`
|
|
259
|
+
: undefined,
|
|
260
|
+
replyTo:
|
|
261
|
+
input.kind === "unviewed-reminder"
|
|
262
|
+
? (validReplyTo(input.senderEmail) ?? FRIENDLY_REPLY_TO)
|
|
263
|
+
: FRIENDLY_REPLY_TO,
|
|
264
|
+
timeoutMs: EMAIL_SEND_TIMEOUT_MS,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
export const renderTransactionalEmail = renderClipsTransactionalEmail;
|
|
269
|
+
export const sendTransactionalEmail = sendClipsTransactionalEmail;
|
|
270
|
+
export type TransactionalEmailInput = ClipsTransactionalEmailInput;
|
|
@@ -11,6 +11,7 @@ import registerMediaVerificationJob from "../jobs/media-verification.js";
|
|
|
11
11
|
import registerMeetingRemindersJob from "../jobs/meeting-reminders.js";
|
|
12
12
|
import registerPollCalendarsJob from "../jobs/poll-calendars.js";
|
|
13
13
|
import registerStaleMeetingSweeperJob from "../jobs/stale-meeting-sweeper.js";
|
|
14
|
+
import registerTransactionalEmailsJob from "../jobs/transactional-emails.js";
|
|
14
15
|
|
|
15
16
|
export default () => {
|
|
16
17
|
// The reminder job registers the `meeting-reminder` event on every
|
|
@@ -22,4 +23,5 @@ export default () => {
|
|
|
22
23
|
registerMediaVerificationJob();
|
|
23
24
|
registerPollCalendarsJob();
|
|
24
25
|
registerStaleMeetingSweeperJob();
|
|
26
|
+
registerTransactionalEmailsJob();
|
|
25
27
|
};
|
|
@@ -33,11 +33,16 @@ import {
|
|
|
33
33
|
runWithRequestContext,
|
|
34
34
|
} from "@agent-native/core/server";
|
|
35
35
|
import { resolveAccess } from "@agent-native/core/sharing";
|
|
36
|
-
import { and, asc, eq, isNull, sql } from "drizzle-orm";
|
|
36
|
+
import { and, asc, eq, isNull, not, or, sql } from "drizzle-orm";
|
|
37
37
|
import { defineEventHandler, getRequestIP, setResponseStatus } from "h3";
|
|
38
38
|
|
|
39
39
|
import { getDb, schema } from "../../db/index.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
nanoid,
|
|
42
|
+
ownerEmailMatches,
|
|
43
|
+
shouldCountView,
|
|
44
|
+
} from "../../lib/recordings.js";
|
|
45
|
+
import { transactionalEmailStore } from "../../lib/transactional-email-store.js";
|
|
41
46
|
|
|
42
47
|
interface ViewEventBody {
|
|
43
48
|
recordingId?: string;
|
|
@@ -408,6 +413,58 @@ export default defineEventHandler(async (event) => {
|
|
|
408
413
|
return updated;
|
|
409
414
|
});
|
|
410
415
|
|
|
416
|
+
const ownerEmail = rec.ownerEmail?.trim();
|
|
417
|
+
const transitionedToCountedView =
|
|
418
|
+
!existing.countedView && persisted.countedView;
|
|
419
|
+
const viewerIsOwner =
|
|
420
|
+
Boolean(viewerEmail && ownerEmail) &&
|
|
421
|
+
viewerEmail!.trim().toLowerCase() === ownerEmail!.toLowerCase();
|
|
422
|
+
if (transitionedToCountedView && ownerEmail && !viewerIsOwner) {
|
|
423
|
+
try {
|
|
424
|
+
const { enabledAt } = await transactionalEmailStore.ensureEnabledAt();
|
|
425
|
+
const [firstNonOwnerView] = await db
|
|
426
|
+
.select({
|
|
427
|
+
id: schema.recordingViews.id,
|
|
428
|
+
viewerKey: schema.recordingViews.viewerKey,
|
|
429
|
+
viewSessionId: schema.recordingViews.viewSessionId,
|
|
430
|
+
viewedAt: schema.recordingViews.viewedAt,
|
|
431
|
+
})
|
|
432
|
+
.from(schema.recordingViews)
|
|
433
|
+
.where(
|
|
434
|
+
and(
|
|
435
|
+
eq(schema.recordingViews.recordingId, recordingId),
|
|
436
|
+
or(
|
|
437
|
+
isNull(schema.recordingViews.viewerEmail),
|
|
438
|
+
not(
|
|
439
|
+
ownerEmailMatches(
|
|
440
|
+
schema.recordingViews.viewerEmail,
|
|
441
|
+
ownerEmail,
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
),
|
|
445
|
+
),
|
|
446
|
+
)
|
|
447
|
+
.orderBy(
|
|
448
|
+
asc(schema.recordingViews.viewedAt),
|
|
449
|
+
asc(schema.recordingViews.id),
|
|
450
|
+
)
|
|
451
|
+
.limit(1);
|
|
452
|
+
const isCurrentCountedView =
|
|
453
|
+
firstNonOwnerView?.viewerKey === viewerKey &&
|
|
454
|
+
firstNonOwnerView.viewSessionId === countedViewSessionId;
|
|
455
|
+
if (isCurrentCountedView && firstNonOwnerView.viewedAt >= enabledAt) {
|
|
456
|
+
await transactionalEmailStore.enqueue(`first-view:${recordingId}`, {
|
|
457
|
+
type: "first-view",
|
|
458
|
+
recipient: ownerEmail,
|
|
459
|
+
recordingIds: [recordingId],
|
|
460
|
+
requestedBy: ownerEmail,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
} catch (err) {
|
|
464
|
+
console.warn("[view-event] first-view email enqueue failed:", err);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
411
468
|
// Only broadcast a refresh signal on "meaningful" events to avoid
|
|
412
469
|
// spamming the polling clients every 2s with watch-progress
|
|
413
470
|
// heartbeats. Skip for anonymous viewers — application_state writes
|
|
@@ -161,8 +161,14 @@ adding fields without returning to the table header. The New property picker
|
|
|
161
161
|
supports searching property types by label or machine name.
|
|
162
162
|
|
|
163
163
|
In unconstrained table views, row drag handles can reorder database item
|
|
164
|
-
pages through `move-database-item`;
|
|
165
|
-
|
|
164
|
+
pages through `move-database-item`; pass both `databaseId` and `itemId` so the
|
|
165
|
+
membership target is exact, and clear search, sort, and filters before manual
|
|
166
|
+
reordering. Pinned and workspace-root sidebar rows also reorder exact
|
|
167
|
+
memberships, but they are references: moving one never reparents, transfers,
|
|
168
|
+
or changes access to the referenced page. Files sidebar Custom order is
|
|
169
|
+
different again: persist it per user and per database view with
|
|
170
|
+
`update-content-database-personal-view`, without changing the shared Files
|
|
171
|
+
membership order. Creating a database row returns the created item IDs and
|
|
166
172
|
opens the new row page in the side preview. Duplicating a database row
|
|
167
173
|
returns the duplicate item IDs and opens the copied row in the side preview
|
|
168
174
|
so users can continue editing the new page immediately, including from
|
|
@@ -88,3 +88,11 @@ Every action carries its own schema, and the rest of the app-specific surface
|
|
|
88
88
|
(comments, sharing, databases, Notion, local file sources such as
|
|
89
89
|
`remove-local-file-source`) is registered too — use `tool-search` instead of
|
|
90
90
|
scanning a table here.
|
|
91
|
+
|
|
92
|
+
Sidebar ordering has two deliberately different meanings. Reordering Pinned or
|
|
93
|
+
workspace roots moves the exact database membership identified by both
|
|
94
|
+
`databaseId` and `itemId`; it never reparents or moves the referenced document.
|
|
95
|
+
Files sidebar Custom order is a per-user database-view preference written with
|
|
96
|
+
`update-content-database-personal-view`, so it must not change shared Files
|
|
97
|
+
membership positions. Ordinary unconstrained database row reordering remains a
|
|
98
|
+
shared database mutation through `move-database-item`.
|
|
@@ -42,6 +42,11 @@ export const filterSchema = z.object({
|
|
|
42
42
|
parentFilterGroupId: z.string().optional(),
|
|
43
43
|
});
|
|
44
44
|
|
|
45
|
+
export const sidebarOrderSchema = z.object({
|
|
46
|
+
mode: z.enum(["custom", "last_edited", "name", "created"]),
|
|
47
|
+
itemIds: z.array(z.string()),
|
|
48
|
+
});
|
|
49
|
+
|
|
45
50
|
const personalViewOverridesFields = {
|
|
46
51
|
activeViewId: z.string().optional(),
|
|
47
52
|
views: z.array(
|
|
@@ -50,6 +55,7 @@ const personalViewOverridesFields = {
|
|
|
50
55
|
sorts: z.array(sortSchema).default([]),
|
|
51
56
|
filters: z.array(filterSchema).default([]),
|
|
52
57
|
filterMode: z.enum(["and", "or"]).default("and"),
|
|
58
|
+
sidebarOrder: sidebarOrderSchema.optional(),
|
|
53
59
|
}),
|
|
54
60
|
),
|
|
55
61
|
};
|
|
@@ -59,6 +65,24 @@ export const personalViewOverridesSchema = z.object({
|
|
|
59
65
|
...personalViewOverridesFields,
|
|
60
66
|
});
|
|
61
67
|
|
|
68
|
+
export function normalizePersonalDatabaseViewOverrides(
|
|
69
|
+
overrides: z.infer<typeof personalViewOverridesSchema>,
|
|
70
|
+
validItemIds?: ReadonlySet<string>,
|
|
71
|
+
) {
|
|
72
|
+
return {
|
|
73
|
+
...overrides,
|
|
74
|
+
views: overrides.views.map((view) => ({
|
|
75
|
+
...view,
|
|
76
|
+
sidebarOrder: {
|
|
77
|
+
mode: view.sidebarOrder?.mode ?? "custom",
|
|
78
|
+
itemIds: [...new Set(view.sidebarOrder?.itemIds ?? [])].filter(
|
|
79
|
+
(itemId) => !validItemIds || validItemIds.has(itemId),
|
|
80
|
+
),
|
|
81
|
+
},
|
|
82
|
+
})),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
62
86
|
const legacyPersonalViewOverridesSchema = z.object({
|
|
63
87
|
version: z.literal(1),
|
|
64
88
|
...personalViewOverridesFields,
|
|
@@ -94,7 +118,8 @@ export async function readPersonalDatabaseViewOverrides(
|
|
|
94
118
|
personalDatabaseViewSettingKey(databaseId),
|
|
95
119
|
);
|
|
96
120
|
const parsed = personalViewOverridesSchema.safeParse(stored);
|
|
97
|
-
if (parsed.success)
|
|
121
|
+
if (parsed.success)
|
|
122
|
+
return normalizePersonalDatabaseViewOverrides(parsed.data);
|
|
98
123
|
|
|
99
124
|
const legacy = legacyPersonalViewOverridesSchema.safeParse(stored);
|
|
100
125
|
if (!legacy.success) return null;
|
|
@@ -103,7 +128,7 @@ export async function readPersonalDatabaseViewOverrides(
|
|
|
103
128
|
.from(schema.contentDatabases)
|
|
104
129
|
.where(eq(schema.contentDatabases.id, databaseId));
|
|
105
130
|
const legacyParentKey = filesParentPropertyId(databaseId);
|
|
106
|
-
return {
|
|
131
|
+
return normalizePersonalDatabaseViewOverrides({
|
|
107
132
|
...legacy.data,
|
|
108
133
|
version: PERSONAL_DATABASE_VIEW_OVERRIDES_VERSION,
|
|
109
134
|
views: legacy.data.views.map((view) => ({
|
|
@@ -120,5 +145,5 @@ export async function readPersonalDatabaseViewOverrides(
|
|
|
120
145
|
)
|
|
121
146
|
: view.filters,
|
|
122
147
|
})),
|
|
123
|
-
};
|
|
148
|
+
});
|
|
124
149
|
}
|
|
@@ -128,7 +128,31 @@ export async function getDatabaseMembershipForDocument(
|
|
|
128
128
|
|
|
129
129
|
export async function resolvePropertyDatabaseForDocument(
|
|
130
130
|
document: DocumentRow,
|
|
131
|
+
databaseId?: string,
|
|
132
|
+
role: "viewer" | "editor" | "admin" = "viewer",
|
|
131
133
|
): Promise<ContentDatabaseRow | null> {
|
|
134
|
+
if (databaseId) {
|
|
135
|
+
const database = await getDatabaseById(databaseId);
|
|
136
|
+
if (!database) throw new Error(`Database "${databaseId}" not found`);
|
|
137
|
+
await assertAccess("document", database.documentId, role);
|
|
138
|
+
if (database.documentId === document.id) return database;
|
|
139
|
+
|
|
140
|
+
const db = getDb();
|
|
141
|
+
const [membership] = await db
|
|
142
|
+
.select({ id: schema.contentDatabaseItems.id })
|
|
143
|
+
.from(schema.contentDatabaseItems)
|
|
144
|
+
.where(
|
|
145
|
+
and(
|
|
146
|
+
eq(schema.contentDatabaseItems.databaseId, databaseId),
|
|
147
|
+
eq(schema.contentDatabaseItems.documentId, document.id),
|
|
148
|
+
),
|
|
149
|
+
);
|
|
150
|
+
if (!membership) {
|
|
151
|
+
throw new Error("Document is not part of this database.");
|
|
152
|
+
}
|
|
153
|
+
return database;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
const ownedDatabase = await getDatabaseForDocument(document.id);
|
|
133
157
|
if (ownedDatabase) return ownedDatabase;
|
|
134
158
|
const membership = await getDatabaseMembershipForDocument(document.id);
|
|
@@ -456,8 +480,14 @@ function normalizeStringList(value: unknown) {
|
|
|
456
480
|
: [];
|
|
457
481
|
}
|
|
458
482
|
|
|
459
|
-
export async function listPropertiesForDocument(
|
|
460
|
-
|
|
483
|
+
export async function listPropertiesForDocument(
|
|
484
|
+
document: DocumentRow,
|
|
485
|
+
databaseId?: string,
|
|
486
|
+
) {
|
|
487
|
+
const database = await resolvePropertyDatabaseForDocument(
|
|
488
|
+
document,
|
|
489
|
+
databaseId,
|
|
490
|
+
);
|
|
461
491
|
if (!database) return [];
|
|
462
492
|
// Read path: PURE read. Seeding the primary Blocks field happens at create
|
|
463
493
|
// time and via the one-time startup repair (repairUnseededBlocksFields) —
|
|
@@ -35,6 +35,12 @@ export default defineAction({
|
|
|
35
35
|
documentId: z
|
|
36
36
|
.string()
|
|
37
37
|
.describe("Document ID used to scope the property workspace"),
|
|
38
|
+
databaseId: z
|
|
39
|
+
.string()
|
|
40
|
+
.optional()
|
|
41
|
+
.describe(
|
|
42
|
+
"Database ID that owns the property; omit only for context-free entry points",
|
|
43
|
+
),
|
|
38
44
|
name: z.string().min(1).describe("Property name"),
|
|
39
45
|
description: z
|
|
40
46
|
.string()
|
|
@@ -96,7 +102,11 @@ export default defineAction({
|
|
|
96
102
|
const name = args.name.trim();
|
|
97
103
|
const type = args.type as DocumentPropertyType;
|
|
98
104
|
let optionsJson = optionsForNewProperty(type, args.options as any);
|
|
99
|
-
const database = await resolvePropertyDatabaseForDocument(
|
|
105
|
+
const database = await resolvePropertyDatabaseForDocument(
|
|
106
|
+
document,
|
|
107
|
+
args.databaseId,
|
|
108
|
+
"editor",
|
|
109
|
+
);
|
|
100
110
|
if (!database) {
|
|
101
111
|
throw new Error(
|
|
102
112
|
"Properties belong to databases. Create or open a database before adding properties.",
|
|
@@ -226,7 +236,7 @@ export default defineAction({
|
|
|
226
236
|
return {
|
|
227
237
|
documentId: args.documentId,
|
|
228
238
|
databaseId: database.id,
|
|
229
|
-
properties: await listPropertiesForDocument(document),
|
|
239
|
+
properties: await listPropertiesForDocument(document, database.id),
|
|
230
240
|
};
|
|
231
241
|
},
|
|
232
242
|
});
|
|
@@ -21,13 +21,23 @@ export default defineAction({
|
|
|
21
21
|
"Delete a Notion-style property definition and its stored document values.",
|
|
22
22
|
schema: z.object({
|
|
23
23
|
documentId: z.string().describe("Document ID used to scope access"),
|
|
24
|
+
databaseId: z
|
|
25
|
+
.string()
|
|
26
|
+
.optional()
|
|
27
|
+
.describe(
|
|
28
|
+
"Database ID that owns the property; omit only for context-free entry points",
|
|
29
|
+
),
|
|
24
30
|
propertyId: z.string().describe("Property definition ID to delete"),
|
|
25
31
|
}),
|
|
26
|
-
run: async ({ documentId, propertyId }) => {
|
|
32
|
+
run: async ({ documentId, databaseId, propertyId }) => {
|
|
27
33
|
const access = await assertAccess("document", documentId, "editor");
|
|
28
34
|
const document = access.resource;
|
|
29
35
|
const db = getDb();
|
|
30
|
-
const database = await resolvePropertyDatabaseForDocument(
|
|
36
|
+
const database = await resolvePropertyDatabaseForDocument(
|
|
37
|
+
document,
|
|
38
|
+
databaseId,
|
|
39
|
+
"editor",
|
|
40
|
+
);
|
|
31
41
|
if (!database) throw new Error("Document is not part of a database.");
|
|
32
42
|
|
|
33
43
|
const [definition] = await db
|
|
@@ -129,7 +139,7 @@ export default defineAction({
|
|
|
129
139
|
return {
|
|
130
140
|
documentId,
|
|
131
141
|
databaseId: database.id,
|
|
132
|
-
properties: await listPropertiesForDocument(document),
|
|
142
|
+
properties: await listPropertiesForDocument(document, database.id),
|
|
133
143
|
};
|
|
134
144
|
},
|
|
135
145
|
});
|
|
@@ -25,13 +25,23 @@ export default defineAction({
|
|
|
25
25
|
"Duplicate a Notion-style property definition and copy its stored values.",
|
|
26
26
|
schema: z.object({
|
|
27
27
|
documentId: z.string().describe("Document ID used to scope access"),
|
|
28
|
+
databaseId: z
|
|
29
|
+
.string()
|
|
30
|
+
.optional()
|
|
31
|
+
.describe(
|
|
32
|
+
"Database ID that owns the property; omit only for context-free entry points",
|
|
33
|
+
),
|
|
28
34
|
propertyId: z.string().describe("Property definition ID to duplicate"),
|
|
29
35
|
}),
|
|
30
|
-
run: async ({ documentId, propertyId }) => {
|
|
36
|
+
run: async ({ documentId, databaseId, propertyId }) => {
|
|
31
37
|
const access = await assertAccess("document", documentId, "editor");
|
|
32
38
|
const document = access.resource;
|
|
33
39
|
const db = getDb();
|
|
34
|
-
const database = await resolvePropertyDatabaseForDocument(
|
|
40
|
+
const database = await resolvePropertyDatabaseForDocument(
|
|
41
|
+
document,
|
|
42
|
+
databaseId,
|
|
43
|
+
"editor",
|
|
44
|
+
);
|
|
35
45
|
if (!database) throw new Error("Document is not part of a database.");
|
|
36
46
|
|
|
37
47
|
const [definition] = await db
|
|
@@ -123,7 +133,7 @@ export default defineAction({
|
|
|
123
133
|
return {
|
|
124
134
|
documentId,
|
|
125
135
|
databaseId: database.id,
|
|
126
|
-
properties: await listPropertiesForDocument(document),
|
|
136
|
+
properties: await listPropertiesForDocument(document, database.id),
|
|
127
137
|
};
|
|
128
138
|
},
|
|
129
139
|
});
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { defineAction } from "@agent-native/core";
|
|
2
2
|
import { getRequestUserEmail } from "@agent-native/core/server/request-context";
|
|
3
|
-
import { and, eq, inArray, isNull } from "drizzle-orm";
|
|
3
|
+
import { and, asc, eq, inArray, isNull } from "drizzle-orm";
|
|
4
4
|
import { z } from "zod";
|
|
5
5
|
|
|
6
6
|
import { getDb, schema } from "../server/db/index.js";
|
|
@@ -65,7 +65,8 @@ export default defineAction({
|
|
|
65
65
|
),
|
|
66
66
|
isNull(schema.contentSpaces.archivedAt),
|
|
67
67
|
),
|
|
68
|
-
)
|
|
68
|
+
)
|
|
69
|
+
.orderBy(asc(schema.contentDatabaseItems.position));
|
|
69
70
|
const filesDatabaseIds = rows.map((row) => row.space.filesDatabaseId);
|
|
70
71
|
const databaseIds = [...filesDatabaseIds, favoritesIds.databaseId];
|
|
71
72
|
const filesDatabases = databaseIds.length
|
|
@@ -90,6 +91,7 @@ export default defineAction({
|
|
|
90
91
|
role: string;
|
|
91
92
|
catalogItemId: string;
|
|
92
93
|
catalogDocumentId: string;
|
|
94
|
+
catalogPosition: number;
|
|
93
95
|
}>;
|
|
94
96
|
for (const row of rows) {
|
|
95
97
|
if (
|
|
@@ -117,6 +119,7 @@ export default defineAction({
|
|
|
117
119
|
role,
|
|
118
120
|
catalogItemId: row.mapping.databaseItemId,
|
|
119
121
|
catalogDocumentId: row.mapping.documentId,
|
|
122
|
+
catalogPosition: row.item.position,
|
|
120
123
|
});
|
|
121
124
|
}
|
|
122
125
|
return {
|
|
@@ -14,21 +14,30 @@ export default defineAction({
|
|
|
14
14
|
"List the Notion-style property definitions and values for one document.",
|
|
15
15
|
schema: z.object({
|
|
16
16
|
documentId: z.string().describe("Document ID (required)"),
|
|
17
|
+
databaseId: z
|
|
18
|
+
.string()
|
|
19
|
+
.optional()
|
|
20
|
+
.describe(
|
|
21
|
+
"Database ID that owns the properties; omit only for context-free entry points",
|
|
22
|
+
),
|
|
17
23
|
}),
|
|
18
24
|
http: { method: "GET" },
|
|
19
25
|
readOnly: true,
|
|
20
|
-
run: async ({ documentId }) => {
|
|
26
|
+
run: async ({ documentId, databaseId }) => {
|
|
21
27
|
const access = await resolveAccess("document", documentId);
|
|
22
28
|
if (!access) throw new Error(`Document "${documentId}" not found`);
|
|
23
29
|
if (await isSoftDeletedDatabaseDocument(documentId)) {
|
|
24
30
|
throw new Error(`Document "${documentId}" not found`);
|
|
25
31
|
}
|
|
26
|
-
const database = await resolvePropertyDatabaseForDocument(
|
|
32
|
+
const database = await resolvePropertyDatabaseForDocument(
|
|
33
|
+
access.resource,
|
|
34
|
+
databaseId,
|
|
35
|
+
);
|
|
27
36
|
|
|
28
37
|
return {
|
|
29
38
|
documentId,
|
|
30
39
|
databaseId: database?.id ?? null,
|
|
31
|
-
properties: await listPropertiesForDocument(access.resource),
|
|
40
|
+
properties: await listPropertiesForDocument(access.resource, databaseId),
|
|
32
41
|
};
|
|
33
42
|
},
|
|
34
43
|
});
|