@plandesk/api 0.20.0 → 1.0.0-beta.1
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/dist/access-control.d.ts +150 -0
- package/dist/access-control.js +63 -0
- package/dist/agent-keys.d.ts +81 -0
- package/dist/agent-keys.js +181 -0
- package/dist/auth-context.d.ts +60 -0
- package/dist/auth-context.js +41 -0
- package/dist/auth.d.ts +33 -0
- package/dist/auth.js +357 -0
- package/dist/better-auth.d.ts +66 -0
- package/dist/better-auth.js +106 -0
- package/dist/github.d.ts +57 -0
- package/dist/github.js +115 -0
- package/dist/hosted-auth.d.ts +23 -0
- package/dist/hosted-auth.js +49 -0
- package/dist/identity.d.ts +38 -0
- package/dist/identity.js +163 -0
- package/dist/index.d.ts +13 -2
- package/dist/index.js +12 -2
- package/dist/invitations.d.ts +94 -0
- package/dist/invitations.js +211 -0
- package/dist/organizations.d.ts +32 -0
- package/dist/organizations.js +63 -0
- package/dist/permissions.d.ts +19 -0
- package/dist/permissions.js +42 -0
- package/dist/projection.d.ts +1 -1
- package/dist/projection.js +5 -5
- package/dist/routes/agent-runs.js +3 -3
- package/dist/routes/artifacts.js +6 -6
- package/dist/routes/auth.d.ts +13 -0
- package/dist/routes/auth.js +83 -0
- package/dist/routes/canvas.js +5 -5
- package/dist/routes/comments.js +11 -11
- package/dist/routes/documents.js +10 -10
- package/dist/routes/folders.js +8 -8
- package/dist/routes/goals.js +11 -11
- package/dist/routes/notes.js +8 -8
- package/dist/routes/orgs.d.ts +17 -0
- package/dist/routes/orgs.js +241 -0
- package/dist/routes/projects.js +13 -13
- package/dist/routes/shares.js +92 -3
- package/dist/routes/submissions.js +7 -8
- package/dist/routes/tags.js +6 -6
- package/dist/routes/tasks.js +14 -3
- package/dist/routes/tokens.js +4 -4
- package/dist/serialize.d.ts +3 -3
- package/dist/server.d.ts +20 -2
- package/dist/server.js +57 -9
- package/dist/services/agent-runs.d.ts +13 -14
- package/dist/services/agent-runs.js +39 -41
- package/dist/services/artifacts.d.ts +6 -7
- package/dist/services/artifacts.js +30 -26
- package/dist/services/canvas.d.ts +11 -12
- package/dist/services/canvas.js +58 -41
- package/dist/services/comments.d.ts +11 -12
- package/dist/services/comments.js +44 -64
- package/dist/services/documents.d.ts +10 -11
- package/dist/services/documents.js +74 -55
- package/dist/services/files.d.ts +2 -1
- package/dist/services/files.js +13 -5
- package/dist/services/folders.d.ts +7 -8
- package/dist/services/folders.js +45 -33
- package/dist/services/goals.d.ts +24 -25
- package/dist/services/goals.js +60 -53
- package/dist/services/index.d.ts +2 -5
- package/dist/services/index.js +15 -20
- package/dist/services/notes.d.ts +7 -8
- package/dist/services/notes.js +35 -23
- package/dist/services/org-scope.d.ts +22 -0
- package/dist/services/org-scope.js +38 -0
- package/dist/services/projects.d.ts +12 -13
- package/dist/services/projects.js +90 -58
- package/dist/services/scope.d.ts +12 -0
- package/dist/services/scope.js +29 -0
- package/dist/services/share.d.ts +74 -10
- package/dist/services/share.js +213 -23
- package/dist/services/sync.d.ts +7 -29
- package/dist/services/sync.js +37 -142
- package/dist/services/tags.d.ts +6 -7
- package/dist/services/tags.js +34 -22
- package/dist/services/tasks.d.ts +24 -17
- package/dist/services/tasks.js +97 -58
- package/dist/services/tokens.d.ts +6 -5
- package/dist/services/tokens.js +13 -6
- package/dist/session.d.ts +5 -0
- package/dist/session.js +8 -0
- package/dist/storage/local.js +16 -10
- package/dist/storage/s3.js +11 -6
- package/dist/test-ba-org.d.ts +16 -0
- package/dist/test-ba-org.js +35 -0
- package/dist/test-helpers.d.ts +21 -5
- package/dist/test-helpers.js +45 -8
- package/dist/vercel.d.ts +3 -0
- package/dist/vercel.js +62 -0
- package/dist/worker.d.ts +44 -0
- package/dist/worker.js +101 -0
- package/package.json +8 -5
- package/web/assets/index-D29SyYFS.css +2 -0
- package/web/assets/index-tBJ_K3ur.js +358 -0
- package/web/index.html +2 -2
- package/web/assets/index-CeBvZ-aq.css +0 -2
- package/web/assets/index-CuRAxImC.js +0 -358
package/dist/services/share.js
CHANGED
|
@@ -1,5 +1,10 @@
|
|
|
1
|
-
import { createShare as dbCreateShare, getDocument, getProject, getShare as dbGetShare, getShareByTokenHashRaw, getTask, hashShareToken, listDocuments, listShares as dbListShares, parseSharePermissions, parseSharePolicy, revokeShare as dbRevokeShare, } from '@plandesk/db';
|
|
1
|
+
import { countRecentSubmissionsByParticipant, createGuestSession, createGuestSubmission, createShare as dbCreateShare, getDocument, getGuestSessionById, getProject, getShare as dbGetShare, getShareByTokenHashRaw, getTask, hashShareToken, listDocuments, listShares as dbListShares, listSubmissionsByShareAndParticipant, parseSharePermissions, parseSharePolicy, revokeShare as dbRevokeShare, } from '@plandesk/db';
|
|
2
|
+
import { getAuthContext } from '../auth-context.js';
|
|
2
3
|
import { buildClientView } from '../projection.js';
|
|
4
|
+
import { assertPermission, resolveOrgId } from './org-scope.js';
|
|
5
|
+
import { assertProjectInOrg, ProjectNotInOrgError } from './scope.js';
|
|
6
|
+
const GUEST_SUBMIT_RATE_LIMIT = 10;
|
|
7
|
+
const GUEST_SUBMIT_RATE_WINDOW_MS = 60_000;
|
|
3
8
|
export class InvalidShareError extends Error {
|
|
4
9
|
constructor(message) {
|
|
5
10
|
super(message);
|
|
@@ -22,6 +27,34 @@ export function serializeShare(share) {
|
|
|
22
27
|
};
|
|
23
28
|
}
|
|
24
29
|
const RESOURCE_SHARE_DEFAULT_TTL_MS = 24 * 60 * 60 * 1000;
|
|
30
|
+
function normalizeEmail(email) {
|
|
31
|
+
return email.trim().toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
function parseInvitedEmails(raw) {
|
|
34
|
+
if (raw === null || raw === '') {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
const parsed = JSON.parse(raw);
|
|
38
|
+
if (!Array.isArray(parsed)) {
|
|
39
|
+
return [];
|
|
40
|
+
}
|
|
41
|
+
return parsed.filter((value) => typeof value === 'string').map(normalizeEmail);
|
|
42
|
+
}
|
|
43
|
+
function isEmailInvited(share, email) {
|
|
44
|
+
if (email === undefined || email.trim() === '') {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
return parseInvitedEmails(share.invitedEmails).includes(normalizeEmail(email));
|
|
48
|
+
}
|
|
49
|
+
function isShareLive(share, now = new Date()) {
|
|
50
|
+
if (share.revokedAt !== null) {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
if (share.expiresAt !== null && share.expiresAt <= now) {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
25
58
|
// The rich-text editor stores document/comment bodies as HTML; this is a
|
|
26
59
|
// deliberately small converter for that constrained, known markup (not
|
|
27
60
|
// arbitrary HTML) rather than pulling in a DOM/HTML-parsing dependency.
|
|
@@ -131,15 +164,21 @@ function buildShareMarkdown(view, policy, origin) {
|
|
|
131
164
|
export function createShareService(deps) {
|
|
132
165
|
const { db } = deps;
|
|
133
166
|
return {
|
|
134
|
-
createShare(projectId, input) {
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
167
|
+
async createShare(projectId, input) {
|
|
168
|
+
assertPermission(deps, 'document', 'create');
|
|
169
|
+
try {
|
|
170
|
+
await assertProjectInOrg(db, projectId, resolveOrgId(deps));
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
if (error instanceof ProjectNotInOrgError) {
|
|
174
|
+
return undefined;
|
|
175
|
+
}
|
|
176
|
+
throw error;
|
|
138
177
|
}
|
|
139
178
|
if (input.audienceName.trim() === '') {
|
|
140
179
|
throw new InvalidShareError('Share audience name must not be empty');
|
|
141
180
|
}
|
|
142
|
-
const { share, token } = dbCreateShare(db, {
|
|
181
|
+
const { share, token } = await dbCreateShare(db, {
|
|
143
182
|
projectId,
|
|
144
183
|
audienceName: input.audienceName,
|
|
145
184
|
mode: input.mode,
|
|
@@ -150,34 +189,41 @@ export function createShareService(deps) {
|
|
|
150
189
|
});
|
|
151
190
|
return { share: serializeShare(share), token };
|
|
152
191
|
},
|
|
153
|
-
listShares(projectId) {
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return undefined;
|
|
192
|
+
async listShares(projectId) {
|
|
193
|
+
try {
|
|
194
|
+
await assertProjectInOrg(db, projectId, resolveOrgId(deps));
|
|
157
195
|
}
|
|
158
|
-
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof ProjectNotInOrgError) {
|
|
198
|
+
return undefined;
|
|
199
|
+
}
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
return (await dbListShares(db, projectId)).map(serializeShare);
|
|
159
203
|
},
|
|
160
|
-
revokeShare(id) {
|
|
161
|
-
|
|
204
|
+
async revokeShare(id) {
|
|
205
|
+
assertPermission(deps, 'document', 'delete');
|
|
206
|
+
return (await dbRevokeShare(db, id)) !== undefined;
|
|
162
207
|
},
|
|
163
|
-
buildClientView(projectId, shareId) {
|
|
164
|
-
const share = dbGetShare(db, shareId);
|
|
208
|
+
async buildClientView(projectId, shareId) {
|
|
209
|
+
const share = await dbGetShare(db, shareId);
|
|
165
210
|
if (!share || share.projectId !== projectId) {
|
|
166
211
|
return undefined;
|
|
167
212
|
}
|
|
168
213
|
return buildClientView(db, projectId, share);
|
|
169
214
|
},
|
|
170
|
-
createResourceShare(input, origin) {
|
|
215
|
+
async createResourceShare(input, origin) {
|
|
216
|
+
assertPermission(deps, 'document', 'create');
|
|
171
217
|
let projectId;
|
|
172
218
|
let policy;
|
|
173
219
|
let audienceName;
|
|
174
220
|
if (input.resource.kind === 'task') {
|
|
175
|
-
const task = getTask(db, input.resource.id);
|
|
221
|
+
const task = await getTask(db, input.resource.id);
|
|
176
222
|
if (!task) {
|
|
177
223
|
return undefined;
|
|
178
224
|
}
|
|
179
225
|
projectId = task.projectId;
|
|
180
|
-
const linkedDocumentIds = listDocuments(db, projectId)
|
|
226
|
+
const linkedDocumentIds = (await listDocuments(db, projectId))
|
|
181
227
|
.filter((doc) => doc.linkedTaskId === task.id)
|
|
182
228
|
.map((doc) => doc.id);
|
|
183
229
|
policy = {
|
|
@@ -188,7 +234,7 @@ export function createShareService(deps) {
|
|
|
188
234
|
audienceName = `Agent link: ${task.label}`;
|
|
189
235
|
}
|
|
190
236
|
else {
|
|
191
|
-
const document = getDocument(db, input.resource.id);
|
|
237
|
+
const document = await getDocument(db, input.resource.id);
|
|
192
238
|
if (!document) {
|
|
193
239
|
return undefined;
|
|
194
240
|
}
|
|
@@ -199,7 +245,7 @@ export function createShareService(deps) {
|
|
|
199
245
|
const expiresAt = input.expiresAt === null
|
|
200
246
|
? null
|
|
201
247
|
: (input.expiresAt ?? new Date(Date.now() + RESOURCE_SHARE_DEFAULT_TTL_MS));
|
|
202
|
-
const { share, token } = dbCreateShare(db, {
|
|
248
|
+
const { share, token } = await dbCreateShare(db, {
|
|
203
249
|
projectId,
|
|
204
250
|
audienceName,
|
|
205
251
|
mode: 'public',
|
|
@@ -214,8 +260,8 @@ export function createShareService(deps) {
|
|
|
214
260
|
expiresAt: share.expiresAt?.toISOString() ?? null,
|
|
215
261
|
};
|
|
216
262
|
},
|
|
217
|
-
getResourceMarkdown(token, origin) {
|
|
218
|
-
const share = getShareByTokenHashRaw(db, hashShareToken(token));
|
|
263
|
+
async getResourceMarkdown(token, origin) {
|
|
264
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
219
265
|
if (!share) {
|
|
220
266
|
return { status: 'not_found' };
|
|
221
267
|
}
|
|
@@ -223,12 +269,156 @@ export function createShareService(deps) {
|
|
|
223
269
|
if (share.revokedAt !== null || (share.expiresAt !== null && share.expiresAt <= now)) {
|
|
224
270
|
return { status: 'gone' };
|
|
225
271
|
}
|
|
226
|
-
const view = buildClientView(db, share.projectId, share);
|
|
272
|
+
const view = await buildClientView(db, share.projectId, share);
|
|
227
273
|
if (!view) {
|
|
228
274
|
return { status: 'not_found' };
|
|
229
275
|
}
|
|
230
276
|
return { status: 'ok', markdown: buildShareMarkdown(view, parseSharePolicy(share), origin) };
|
|
231
277
|
},
|
|
278
|
+
// Portal meta for the join gate UI. Live shares only; unknown/revoked/expired → not_found.
|
|
279
|
+
async getShareMeta(token) {
|
|
280
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
281
|
+
if (!share || !isShareLive(share)) {
|
|
282
|
+
return { status: 'not_found' };
|
|
283
|
+
}
|
|
284
|
+
return { status: 'ok', audienceName: share.audienceName, mode: share.mode };
|
|
285
|
+
},
|
|
286
|
+
// Named join: mints a guest session scoped to this share. invite mode requires
|
|
287
|
+
// an allow-listed email (sync-server semantics: 403 email_not_invited).
|
|
288
|
+
async joinShare(token, input) {
|
|
289
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
290
|
+
if (!share || !isShareLive(share)) {
|
|
291
|
+
return { status: 'unauthorized' };
|
|
292
|
+
}
|
|
293
|
+
const name = input.name.trim();
|
|
294
|
+
if (name === '') {
|
|
295
|
+
return { status: 'name_required' };
|
|
296
|
+
}
|
|
297
|
+
if (share.mode === 'invite' && !isEmailInvited(share, input.email)) {
|
|
298
|
+
return { status: 'email_not_invited' };
|
|
299
|
+
}
|
|
300
|
+
const { guest, token: sessionToken } = await createGuestSession(db, {
|
|
301
|
+
shareId: share.id,
|
|
302
|
+
projectId: share.projectId,
|
|
303
|
+
name,
|
|
304
|
+
email: input.email?.trim() || undefined,
|
|
305
|
+
});
|
|
306
|
+
return {
|
|
307
|
+
status: 'ok',
|
|
308
|
+
sessionToken,
|
|
309
|
+
participant: { id: guest.id, name: guest.name },
|
|
310
|
+
share: {
|
|
311
|
+
audienceName: share.audienceName,
|
|
312
|
+
permissions: parseSharePermissions(share),
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
},
|
|
316
|
+
// Guest-gated portal view: middleware has already verified the guest session
|
|
317
|
+
// matches this share token. The view is COMPUTED live (not a snapshot). Every
|
|
318
|
+
// failure shape collapses to undefined → uniform 404. Guest context has no
|
|
319
|
+
// orgId; buildClientView reads only this one project.
|
|
320
|
+
async getClientView(token) {
|
|
321
|
+
const auth = getAuthContext();
|
|
322
|
+
if (auth.kind !== 'guest') {
|
|
323
|
+
return undefined;
|
|
324
|
+
}
|
|
325
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
326
|
+
if (!share || !isShareLive(share)) {
|
|
327
|
+
return undefined;
|
|
328
|
+
}
|
|
329
|
+
if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
|
|
330
|
+
return undefined;
|
|
331
|
+
}
|
|
332
|
+
return buildClientView(db, share.projectId, share);
|
|
333
|
+
},
|
|
334
|
+
// Guest moderated inbox: write a pending share_submissions row the owner
|
|
335
|
+
// lists/triages on the same server (no cross-server hop).
|
|
336
|
+
async submitIssue(token, input) {
|
|
337
|
+
const auth = getAuthContext();
|
|
338
|
+
if (auth.kind !== 'guest') {
|
|
339
|
+
return { status: 'unauthorized' };
|
|
340
|
+
}
|
|
341
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
342
|
+
if (!share || !isShareLive(share)) {
|
|
343
|
+
return { status: 'unauthorized' };
|
|
344
|
+
}
|
|
345
|
+
if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
|
|
346
|
+
return { status: 'unauthorized' };
|
|
347
|
+
}
|
|
348
|
+
const permissions = parseSharePermissions(share);
|
|
349
|
+
if (permissions.submit !== true) {
|
|
350
|
+
return { status: 'submit_not_permitted' };
|
|
351
|
+
}
|
|
352
|
+
const title = input.title.trim();
|
|
353
|
+
if (title === '') {
|
|
354
|
+
return { status: 'title_required' };
|
|
355
|
+
}
|
|
356
|
+
const guest = await getGuestSessionById(db, auth.guestSessionId);
|
|
357
|
+
if (guest === undefined || guest.shareId !== share.id) {
|
|
358
|
+
return { status: 'unauthorized' };
|
|
359
|
+
}
|
|
360
|
+
const recent = await countRecentSubmissionsByParticipant(db, {
|
|
361
|
+
hostedShareId: share.id,
|
|
362
|
+
participantName: guest.name,
|
|
363
|
+
since: new Date(Date.now() - GUEST_SUBMIT_RATE_WINDOW_MS),
|
|
364
|
+
});
|
|
365
|
+
if (recent >= GUEST_SUBMIT_RATE_LIMIT) {
|
|
366
|
+
return { status: 'rate_limited' };
|
|
367
|
+
}
|
|
368
|
+
const bodyText = input.body?.trim() === '' ? null : (input.body?.trim() ?? null);
|
|
369
|
+
const severity = input.severity?.trim() === '' ? null : (input.severity?.trim() ?? null);
|
|
370
|
+
const taskRef = input.task_ref?.trim() === '' ? null : (input.task_ref?.trim() ?? null);
|
|
371
|
+
const row = await createGuestSubmission(db, {
|
|
372
|
+
projectId: share.projectId,
|
|
373
|
+
hostedShareId: share.id,
|
|
374
|
+
participantName: guest.name,
|
|
375
|
+
title,
|
|
376
|
+
body: bodyText,
|
|
377
|
+
severity,
|
|
378
|
+
taskRef,
|
|
379
|
+
});
|
|
380
|
+
return {
|
|
381
|
+
status: 'ok',
|
|
382
|
+
submission: {
|
|
383
|
+
id: row.id,
|
|
384
|
+
title: row.title,
|
|
385
|
+
severity: row.severity,
|
|
386
|
+
status: row.status,
|
|
387
|
+
created_at: row.createdAt.toISOString(),
|
|
388
|
+
},
|
|
389
|
+
};
|
|
390
|
+
},
|
|
391
|
+
async listMySubmissions(token) {
|
|
392
|
+
const auth = getAuthContext();
|
|
393
|
+
if (auth.kind !== 'guest') {
|
|
394
|
+
return { status: 'unauthorized' };
|
|
395
|
+
}
|
|
396
|
+
const share = await getShareByTokenHashRaw(db, hashShareToken(token));
|
|
397
|
+
if (!share || !isShareLive(share)) {
|
|
398
|
+
return { status: 'unauthorized' };
|
|
399
|
+
}
|
|
400
|
+
if (share.id !== auth.shareId || share.projectId !== auth.projectId) {
|
|
401
|
+
return { status: 'unauthorized' };
|
|
402
|
+
}
|
|
403
|
+
const guest = await getGuestSessionById(db, auth.guestSessionId);
|
|
404
|
+
if (guest === undefined || guest.shareId !== share.id) {
|
|
405
|
+
return { status: 'unauthorized' };
|
|
406
|
+
}
|
|
407
|
+
const rows = await listSubmissionsByShareAndParticipant(db, {
|
|
408
|
+
hostedShareId: share.id,
|
|
409
|
+
participantName: guest.name,
|
|
410
|
+
});
|
|
411
|
+
return {
|
|
412
|
+
status: 'ok',
|
|
413
|
+
submissions: rows.map((row) => ({
|
|
414
|
+
id: row.id,
|
|
415
|
+
title: row.title,
|
|
416
|
+
severity: row.severity,
|
|
417
|
+
status: row.status,
|
|
418
|
+
created_at: row.createdAt.toISOString(),
|
|
419
|
+
})),
|
|
420
|
+
};
|
|
421
|
+
},
|
|
232
422
|
};
|
|
233
423
|
}
|
|
234
424
|
//# sourceMappingURL=share.js.map
|
package/dist/services/sync.d.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { type Db, type ShareSubmission, type ShareSubmissionStatus } from '@plandesk/db';
|
|
2
|
-
import type
|
|
3
|
-
import type { ShareService } from './share.js';
|
|
2
|
+
import { type OrgScopedDeps } from './org-scope.js';
|
|
4
3
|
import type { TaskService } from './tasks.js';
|
|
5
4
|
export declare class SyncUnavailableError extends Error {
|
|
6
5
|
constructor(message?: string);
|
|
@@ -34,43 +33,22 @@ export type SerializedSubmission = {
|
|
|
34
33
|
pulled_at: string;
|
|
35
34
|
};
|
|
36
35
|
export declare function serializeSubmission(row: ShareSubmission): SerializedSubmission;
|
|
37
|
-
export type SyncServiceDeps = {
|
|
36
|
+
export type SyncServiceDeps = OrgScopedDeps & {
|
|
38
37
|
db: Db;
|
|
39
|
-
eventBus: EventBus;
|
|
40
38
|
taskService: TaskService;
|
|
41
|
-
shareService: ShareService;
|
|
42
|
-
};
|
|
43
|
-
export type WatchPushController = {
|
|
44
|
-
onChange(): void;
|
|
45
|
-
dispose(): void;
|
|
46
|
-
};
|
|
47
|
-
export type WatchPushOptions = {
|
|
48
|
-
debounceMs?: number;
|
|
49
|
-
onPushError?: (err: unknown) => void;
|
|
50
39
|
};
|
|
51
40
|
export declare function createSyncService(deps: SyncServiceDeps): {
|
|
52
|
-
push(projectId: string, remote: SyncRemote): Promise<{
|
|
53
|
-
pushed: number;
|
|
54
|
-
}>;
|
|
55
|
-
publishProject(projectId: string, input: {
|
|
56
|
-
serverUrl: string;
|
|
57
|
-
syncToken: string;
|
|
58
|
-
}): Promise<{
|
|
59
|
-
globalProjectId: string;
|
|
60
|
-
pushed: number;
|
|
61
|
-
}>;
|
|
62
41
|
pull(projectId: string, remote: SyncRemote): Promise<{
|
|
63
42
|
pulled: number;
|
|
64
43
|
}>;
|
|
65
|
-
listTriage(projectId: string, status?: ShareSubmissionStatus): SerializedSubmission[]
|
|
66
|
-
getSubmission(submissionId: string): SerializedSubmission | undefined
|
|
67
|
-
setRemote(projectId: string, remote: SyncRemote): void
|
|
68
|
-
getRemote(projectId: string): SyncRemote | undefined
|
|
69
|
-
triage(submissionId: string, action: "accept" | "reject", remote
|
|
44
|
+
listTriage(projectId: string, status?: ShareSubmissionStatus): Promise<SerializedSubmission[]>;
|
|
45
|
+
getSubmission(submissionId: string): Promise<SerializedSubmission | undefined>;
|
|
46
|
+
setRemote(projectId: string, remote: SyncRemote): Promise<void>;
|
|
47
|
+
getRemote(projectId: string): Promise<SyncRemote | undefined>;
|
|
48
|
+
triage(submissionId: string, action: "accept" | "reject", remote?: SyncRemote, asTask?: {
|
|
70
49
|
label?: string;
|
|
71
50
|
description?: string;
|
|
72
51
|
}, linkTaskId?: string): Promise<SerializedSubmission>;
|
|
73
|
-
watchPush(projectId: string, remote: SyncRemote, opts?: WatchPushOptions): WatchPushController;
|
|
74
52
|
};
|
|
75
53
|
export type SyncService = ReturnType<typeof createSyncService>;
|
|
76
54
|
//# sourceMappingURL=sync.d.ts.map
|
package/dist/services/sync.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import {
|
|
1
|
+
import { getPullCursor, getSubmission as dbGetSubmission, getSyncRemote, listSubmissions, setPullCursor, setSubmissionStatus, setSyncRemote, upsertSubmission, } from '@plandesk/db';
|
|
2
|
+
import { assertPermission } from './org-scope.js';
|
|
3
3
|
export class SyncUnavailableError extends Error {
|
|
4
4
|
constructor(message = 'sync server unavailable') {
|
|
5
5
|
super(message);
|
|
@@ -40,16 +40,6 @@ export function serializeSubmission(row) {
|
|
|
40
40
|
pulled_at: row.pulledAt.toISOString(),
|
|
41
41
|
};
|
|
42
42
|
}
|
|
43
|
-
function parseInvitedEmails(raw) {
|
|
44
|
-
if (raw === null || raw === '') {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
const parsed = JSON.parse(raw);
|
|
48
|
-
if (!Array.isArray(parsed)) {
|
|
49
|
-
return [];
|
|
50
|
-
}
|
|
51
|
-
return parsed.filter((value) => typeof value === 'string');
|
|
52
|
-
}
|
|
53
43
|
function buildDesc(submission) {
|
|
54
44
|
const footer = `Reported by ${submission.participantName} (client) via Plan Desk`;
|
|
55
45
|
if (submission.body === null || submission.body === '') {
|
|
@@ -78,108 +68,19 @@ async function ackSubmission(remote, submissionId, status) {
|
|
|
78
68
|
throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
|
|
79
69
|
}
|
|
80
70
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
try {
|
|
86
|
-
response = await fetch(url, {
|
|
87
|
-
method: 'PUT',
|
|
88
|
-
headers: {
|
|
89
|
-
Authorization: `Bearer ${remote.syncToken}`,
|
|
90
|
-
'Content-Type': 'application/json',
|
|
91
|
-
},
|
|
92
|
-
body: JSON.stringify({
|
|
93
|
-
share: {
|
|
94
|
-
token_hash: share.tokenHash,
|
|
95
|
-
audience_name: share.audienceName,
|
|
96
|
-
permissions: parseSharePermissions(share),
|
|
97
|
-
mode: share.mode,
|
|
98
|
-
invited_emails: parseInvitedEmails(share.invitedEmails),
|
|
99
|
-
expires_at: share.expiresAt?.toISOString() ?? null,
|
|
100
|
-
},
|
|
101
|
-
version: Date.now(),
|
|
102
|
-
view,
|
|
103
|
-
}),
|
|
104
|
-
});
|
|
105
|
-
}
|
|
106
|
-
catch {
|
|
107
|
-
throw new SyncUnavailableError();
|
|
108
|
-
}
|
|
109
|
-
if (response.status === 401) {
|
|
110
|
-
throw new SyncUnauthorizedError();
|
|
111
|
-
}
|
|
112
|
-
if (!response.ok) {
|
|
113
|
-
throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
|
|
71
|
+
/** Optional remote: single-server guest submit has no cross-server ack. */
|
|
72
|
+
async function maybeAck(remote, submissionId, status) {
|
|
73
|
+
if (remote === undefined) {
|
|
74
|
+
return;
|
|
114
75
|
}
|
|
115
|
-
|
|
116
|
-
function createWatchPush(push, projectId, remote, opts) {
|
|
117
|
-
const debounceMs = opts?.debounceMs ?? 1500;
|
|
118
|
-
const onPushError = opts?.onPushError ??
|
|
119
|
-
((err) => {
|
|
120
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
121
|
-
console.error(`sync watch push failed: ${message}`);
|
|
122
|
-
});
|
|
123
|
-
let timer;
|
|
124
|
-
let disposed = false;
|
|
125
|
-
const runPush = () => {
|
|
126
|
-
timer = undefined;
|
|
127
|
-
void push(projectId, remote).catch(onPushError);
|
|
128
|
-
};
|
|
129
|
-
return {
|
|
130
|
-
onChange() {
|
|
131
|
-
if (disposed) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
if (timer !== undefined) {
|
|
135
|
-
clearTimeout(timer);
|
|
136
|
-
}
|
|
137
|
-
timer = setTimeout(runPush, debounceMs);
|
|
138
|
-
},
|
|
139
|
-
dispose() {
|
|
140
|
-
disposed = true;
|
|
141
|
-
if (timer !== undefined) {
|
|
142
|
-
clearTimeout(timer);
|
|
143
|
-
timer = undefined;
|
|
144
|
-
}
|
|
145
|
-
},
|
|
146
|
-
};
|
|
76
|
+
await ackSubmission(remote, submissionId, status);
|
|
147
77
|
}
|
|
148
78
|
export function createSyncService(deps) {
|
|
149
|
-
const { db,
|
|
79
|
+
const { db, taskService } = deps;
|
|
150
80
|
return {
|
|
151
|
-
async push(projectId, remote) {
|
|
152
|
-
const shares = shareService.listShares(projectId);
|
|
153
|
-
if (shares === undefined) {
|
|
154
|
-
throw new SyncUnavailableError('project not found');
|
|
155
|
-
}
|
|
156
|
-
const active = shares.filter((share) => share.revoked_at === null);
|
|
157
|
-
let pushed = 0;
|
|
158
|
-
for (const serialized of active) {
|
|
159
|
-
const shareRow = getShare(db, serialized.id);
|
|
160
|
-
if (shareRow === undefined) {
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
const view = shareService.buildClientView(projectId, serialized.id);
|
|
164
|
-
if (view === undefined) {
|
|
165
|
-
continue;
|
|
166
|
-
}
|
|
167
|
-
await pushProjection(remote, shareRow, view);
|
|
168
|
-
pushed += 1;
|
|
169
|
-
}
|
|
170
|
-
return { pushed };
|
|
171
|
-
},
|
|
172
|
-
async publishProject(projectId, input) {
|
|
173
|
-
const globalProjectId = randomUUID();
|
|
174
|
-
const { pushed } = await this.push(projectId, {
|
|
175
|
-
serverUrl: input.serverUrl,
|
|
176
|
-
globalProjectId,
|
|
177
|
-
syncToken: input.syncToken,
|
|
178
|
-
});
|
|
179
|
-
return { globalProjectId, pushed };
|
|
180
|
-
},
|
|
181
81
|
async pull(projectId, remote) {
|
|
182
|
-
|
|
82
|
+
assertPermission(deps, 'task', 'update');
|
|
83
|
+
const cursor = await getPullCursor(db, projectId);
|
|
183
84
|
const base = remote.serverUrl.replace(/\/$/, '');
|
|
184
85
|
const url = new URL(`${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/submissions`);
|
|
185
86
|
if (cursor !== undefined) {
|
|
@@ -205,7 +106,7 @@ export function createSyncService(deps) {
|
|
|
205
106
|
let pulled = 0;
|
|
206
107
|
let maxCreatedAt = cursor;
|
|
207
108
|
for (const submission of remoteSubmissions) {
|
|
208
|
-
const inserted = upsertSubmission(db, {
|
|
109
|
+
const inserted = await upsertSubmission(db, {
|
|
209
110
|
id: submission.id,
|
|
210
111
|
projectId,
|
|
211
112
|
hostedShareId: submission.share_id,
|
|
@@ -226,29 +127,29 @@ export function createSyncService(deps) {
|
|
|
226
127
|
}
|
|
227
128
|
}
|
|
228
129
|
if (maxCreatedAt !== undefined && maxCreatedAt !== cursor) {
|
|
229
|
-
setPullCursor(db, projectId, maxCreatedAt);
|
|
130
|
+
await setPullCursor(db, projectId, maxCreatedAt);
|
|
230
131
|
}
|
|
231
132
|
if (pulled > 0) {
|
|
232
|
-
eventBus.emit({ type: 'submissions_pulled', projectId });
|
|
233
133
|
}
|
|
234
134
|
return { pulled };
|
|
235
135
|
},
|
|
236
|
-
listTriage(projectId, status) {
|
|
237
|
-
return listSubmissions(db, projectId, status).map(serializeSubmission);
|
|
136
|
+
async listTriage(projectId, status) {
|
|
137
|
+
return (await listSubmissions(db, projectId, status)).map(serializeSubmission);
|
|
238
138
|
},
|
|
239
|
-
getSubmission(submissionId) {
|
|
240
|
-
const submission = dbGetSubmission(db, submissionId);
|
|
139
|
+
async getSubmission(submissionId) {
|
|
140
|
+
const submission = await dbGetSubmission(db, submissionId);
|
|
241
141
|
return submission === undefined ? undefined : serializeSubmission(submission);
|
|
242
142
|
},
|
|
243
|
-
setRemote(projectId, remote) {
|
|
244
|
-
|
|
143
|
+
async setRemote(projectId, remote) {
|
|
144
|
+
assertPermission(deps, 'task', 'update');
|
|
145
|
+
await setSyncRemote(db, projectId, {
|
|
245
146
|
serverUrl: remote.serverUrl,
|
|
246
147
|
globalProjectId: remote.globalProjectId,
|
|
247
148
|
syncToken: remote.syncToken,
|
|
248
149
|
});
|
|
249
150
|
},
|
|
250
|
-
getRemote(projectId) {
|
|
251
|
-
const row = getSyncRemote(db, projectId);
|
|
151
|
+
async getRemote(projectId) {
|
|
152
|
+
const row = await getSyncRemote(db, projectId);
|
|
252
153
|
if (row === undefined) {
|
|
253
154
|
return undefined;
|
|
254
155
|
}
|
|
@@ -259,39 +160,40 @@ export function createSyncService(deps) {
|
|
|
259
160
|
};
|
|
260
161
|
},
|
|
261
162
|
async triage(submissionId, action, remote, asTask, linkTaskId) {
|
|
163
|
+
assertPermission(deps, 'task', 'create');
|
|
262
164
|
if (asTask !== undefined && linkTaskId !== undefined) {
|
|
263
165
|
throw new InvalidTriageInputError('as_task and link_task_id are mutually exclusive');
|
|
264
166
|
}
|
|
265
|
-
const submission = dbGetSubmission(db, submissionId);
|
|
167
|
+
const submission = await dbGetSubmission(db, submissionId);
|
|
266
168
|
if (submission === undefined) {
|
|
267
169
|
throw new InvalidTriageError();
|
|
268
170
|
}
|
|
269
171
|
if (submission.status !== 'pending') {
|
|
270
172
|
// Idempotent retry recovery: a prior triage may have committed the terminal
|
|
271
|
-
// status locally but failed to ack the remote (
|
|
173
|
+
// status locally but failed to ack the remote (legacy remote briefly down),
|
|
272
174
|
// leaving local/remote divergence with no recovery path. Re-ack when the
|
|
273
175
|
// retry's action matches the recorded outcome; ack is idempotent remotely.
|
|
176
|
+
// Single-server (no remote): local status is the only status — no re-ack.
|
|
274
177
|
if ((action === 'accept' && submission.status === 'accepted') ||
|
|
275
178
|
(action === 'reject' && submission.status === 'rejected')) {
|
|
276
|
-
await
|
|
179
|
+
await maybeAck(remote, submissionId, submission.status);
|
|
277
180
|
}
|
|
278
181
|
return serializeSubmission(submission);
|
|
279
182
|
}
|
|
280
183
|
const projectId = submission.projectId;
|
|
281
184
|
if (action === 'accept' && linkTaskId !== undefined) {
|
|
282
|
-
const existingTask = taskService.get(linkTaskId);
|
|
185
|
+
const existingTask = await taskService.get(linkTaskId);
|
|
283
186
|
if (existingTask === undefined || existingTask.project_id !== projectId) {
|
|
284
187
|
throw new InvalidTriageInputError('link_task_id does not reference an existing task in this project');
|
|
285
188
|
}
|
|
286
|
-
const updated = setSubmissionStatus(db, submissionId, {
|
|
189
|
+
const updated = await setSubmissionStatus(db, submissionId, {
|
|
287
190
|
status: 'accepted',
|
|
288
191
|
linkedTaskId: linkTaskId,
|
|
289
192
|
});
|
|
290
193
|
if (updated === undefined) {
|
|
291
194
|
throw new InvalidTriageError();
|
|
292
195
|
}
|
|
293
|
-
|
|
294
|
-
await ackSubmission(remote, submissionId, 'accepted');
|
|
196
|
+
await maybeAck(remote, submissionId, 'accepted');
|
|
295
197
|
return serializeSubmission(updated);
|
|
296
198
|
}
|
|
297
199
|
if (action === 'accept') {
|
|
@@ -300,12 +202,10 @@ export function createSyncService(deps) {
|
|
|
300
202
|
// caller — enforced here at the single service chokepoint so both the HTTP
|
|
301
203
|
// route and the MCP tool are covered.
|
|
302
204
|
//
|
|
303
|
-
// create + setSubmissionStatus run as two
|
|
304
|
-
//
|
|
305
|
-
//
|
|
306
|
-
|
|
307
|
-
// self-transacts, so an outer transaction here is neither possible nor needed.
|
|
308
|
-
const task = taskService.create(projectId, {
|
|
205
|
+
// create + setSubmissionStatus run as two sequential steps after validating
|
|
206
|
+
// the submission exists. taskService.create self-transacts, so an outer
|
|
207
|
+
// transaction here is neither possible nor needed.
|
|
208
|
+
const task = await taskService.create(projectId, {
|
|
309
209
|
label: asTask?.label ?? submission.title,
|
|
310
210
|
status: 'scope',
|
|
311
211
|
description: asTask?.description ?? buildDesc(submission),
|
|
@@ -313,28 +213,23 @@ export function createSyncService(deps) {
|
|
|
313
213
|
if (task === undefined) {
|
|
314
214
|
throw new InvalidTriageError('project not found');
|
|
315
215
|
}
|
|
316
|
-
const updated = setSubmissionStatus(db, submissionId, {
|
|
216
|
+
const updated = await setSubmissionStatus(db, submissionId, {
|
|
317
217
|
status: 'accepted',
|
|
318
218
|
linkedTaskId: task.id,
|
|
319
219
|
});
|
|
320
220
|
if (updated === undefined) {
|
|
321
221
|
throw new InvalidTriageError();
|
|
322
222
|
}
|
|
323
|
-
|
|
324
|
-
await ackSubmission(remote, submissionId, 'accepted');
|
|
223
|
+
await maybeAck(remote, submissionId, 'accepted');
|
|
325
224
|
return serializeSubmission(updated);
|
|
326
225
|
}
|
|
327
|
-
const updated = setSubmissionStatus(db, submissionId, { status: 'rejected' });
|
|
226
|
+
const updated = await setSubmissionStatus(db, submissionId, { status: 'rejected' });
|
|
328
227
|
if (updated === undefined) {
|
|
329
228
|
throw new InvalidTriageError();
|
|
330
229
|
}
|
|
331
|
-
|
|
332
|
-
await ackSubmission(remote, submissionId, 'rejected');
|
|
230
|
+
await maybeAck(remote, submissionId, 'rejected');
|
|
333
231
|
return serializeSubmission(updated);
|
|
334
232
|
},
|
|
335
|
-
watchPush(projectId, remote, opts) {
|
|
336
|
-
return createWatchPush((id, rem) => this.push(id, rem), projectId, remote, opts);
|
|
337
|
-
},
|
|
338
233
|
};
|
|
339
234
|
}
|
|
340
235
|
//# sourceMappingURL=sync.js.map
|