@plandesk/api 0.3.0 → 0.4.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.
Files changed (65) hide show
  1. package/dist/events.d.ts +5 -1
  2. package/dist/index.d.ts +2 -0
  3. package/dist/index.js +2 -0
  4. package/dist/projection.d.ts +51 -0
  5. package/dist/projection.js +75 -0
  6. package/dist/services/index.d.ts +4 -0
  7. package/dist/services/index.js +6 -0
  8. package/dist/services/projects.js +5 -1
  9. package/dist/services/share.d.ts +41 -0
  10. package/dist/services/share.js +65 -0
  11. package/dist/services/sync.d.ts +74 -0
  12. package/dist/services/sync.js +297 -0
  13. package/package.json +5 -5
  14. package/web/_redirects +1 -0
  15. package/web/assets/index-DK5m0p5b.js +171 -0
  16. package/web/index.html +1 -1
  17. package/dist/auth.d.ts.map +0 -1
  18. package/dist/auth.js.map +0 -1
  19. package/dist/events.d.ts.map +0 -1
  20. package/dist/events.js.map +0 -1
  21. package/dist/index.d.ts.map +0 -1
  22. package/dist/index.js.map +0 -1
  23. package/dist/routes/agent-runs.d.ts.map +0 -1
  24. package/dist/routes/agent-runs.js.map +0 -1
  25. package/dist/routes/canvas.d.ts.map +0 -1
  26. package/dist/routes/canvas.js.map +0 -1
  27. package/dist/routes/comments.d.ts.map +0 -1
  28. package/dist/routes/comments.js.map +0 -1
  29. package/dist/routes/documents.d.ts.map +0 -1
  30. package/dist/routes/documents.js.map +0 -1
  31. package/dist/routes/events.d.ts.map +0 -1
  32. package/dist/routes/events.js.map +0 -1
  33. package/dist/routes/health.d.ts.map +0 -1
  34. package/dist/routes/health.js.map +0 -1
  35. package/dist/routes/projects.d.ts.map +0 -1
  36. package/dist/routes/projects.js.map +0 -1
  37. package/dist/routes/tasks.d.ts.map +0 -1
  38. package/dist/routes/tasks.js.map +0 -1
  39. package/dist/routes/tokens.d.ts.map +0 -1
  40. package/dist/routes/tokens.js.map +0 -1
  41. package/dist/serialize.d.ts.map +0 -1
  42. package/dist/serialize.js.map +0 -1
  43. package/dist/server.d.ts.map +0 -1
  44. package/dist/server.js.map +0 -1
  45. package/dist/services/agent-runs.d.ts.map +0 -1
  46. package/dist/services/agent-runs.js.map +0 -1
  47. package/dist/services/canvas.d.ts.map +0 -1
  48. package/dist/services/canvas.js.map +0 -1
  49. package/dist/services/comments.d.ts.map +0 -1
  50. package/dist/services/comments.js.map +0 -1
  51. package/dist/services/documents.d.ts.map +0 -1
  52. package/dist/services/documents.js.map +0 -1
  53. package/dist/services/index.d.ts.map +0 -1
  54. package/dist/services/index.js.map +0 -1
  55. package/dist/services/projects.d.ts.map +0 -1
  56. package/dist/services/projects.js.map +0 -1
  57. package/dist/services/tasks.d.ts.map +0 -1
  58. package/dist/services/tasks.js.map +0 -1
  59. package/dist/services/tokens.d.ts.map +0 -1
  60. package/dist/services/tokens.js.map +0 -1
  61. package/dist/static.d.ts.map +0 -1
  62. package/dist/static.js.map +0 -1
  63. package/dist/test-helpers.d.ts.map +0 -1
  64. package/dist/test-helpers.js.map +0 -1
  65. package/web/assets/index-C7JiW6wQ.js +0 -171
@@ -0,0 +1,297 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { getPullCursor, getShare, getSubmission as dbGetSubmission, getSyncRemote, listSubmissions, parseSharePermissions, setPullCursor, setSubmissionStatus, setSyncRemote, upsertSubmission, } from '@plandesk/db';
3
+ export class SyncUnavailableError extends Error {
4
+ constructor(message = 'sync server unavailable') {
5
+ super(message);
6
+ this.name = 'SyncUnavailableError';
7
+ }
8
+ }
9
+ export class SyncUnauthorizedError extends Error {
10
+ constructor(message = 'sync token unauthorized') {
11
+ super(message);
12
+ this.name = 'SyncUnauthorizedError';
13
+ }
14
+ }
15
+ export class InvalidTriageError extends Error {
16
+ constructor(message = 'submission not found') {
17
+ super(message);
18
+ this.name = 'InvalidTriageError';
19
+ }
20
+ }
21
+ export function serializeSubmission(row) {
22
+ return {
23
+ id: row.id,
24
+ project_id: row.projectId,
25
+ hosted_share_id: row.hostedShareId,
26
+ participant_name: row.participantName,
27
+ title: row.title,
28
+ body: row.body,
29
+ severity: row.severity,
30
+ task_ref: row.taskRef,
31
+ status: row.status,
32
+ linked_task_id: row.linkedTaskId,
33
+ created_at: row.createdAt.toISOString(),
34
+ pulled_at: row.pulledAt.toISOString(),
35
+ };
36
+ }
37
+ function parseInvitedEmails(raw) {
38
+ if (raw === null || raw === '') {
39
+ return [];
40
+ }
41
+ const parsed = JSON.parse(raw);
42
+ if (!Array.isArray(parsed)) {
43
+ return [];
44
+ }
45
+ return parsed.filter((value) => typeof value === 'string');
46
+ }
47
+ function buildDesc(submission) {
48
+ const footer = `Reported by ${submission.participantName} (client) via Plan Desk`;
49
+ if (submission.body === null || submission.body === '') {
50
+ return footer;
51
+ }
52
+ return `${submission.body}\n\n${footer}`;
53
+ }
54
+ async function ackSubmission(remote, submissionId, status) {
55
+ const base = remote.serverUrl.replace(/\/$/, '');
56
+ const url = `${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/submissions/${encodeURIComponent(submissionId)}/ack`;
57
+ let response;
58
+ try {
59
+ response = await fetch(url, {
60
+ method: 'POST',
61
+ headers: {
62
+ Authorization: `Bearer ${remote.syncToken}`,
63
+ 'Content-Type': 'application/json',
64
+ },
65
+ body: JSON.stringify({ status }),
66
+ });
67
+ }
68
+ catch {
69
+ throw new SyncUnavailableError();
70
+ }
71
+ if (!response.ok) {
72
+ throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
73
+ }
74
+ }
75
+ async function pushProjection(remote, share, view) {
76
+ const base = remote.serverUrl.replace(/\/$/, '');
77
+ const url = `${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/projection`;
78
+ let response;
79
+ try {
80
+ response = await fetch(url, {
81
+ method: 'PUT',
82
+ headers: {
83
+ Authorization: `Bearer ${remote.syncToken}`,
84
+ 'Content-Type': 'application/json',
85
+ },
86
+ body: JSON.stringify({
87
+ share: {
88
+ token_hash: share.tokenHash,
89
+ audience_name: share.audienceName,
90
+ permissions: parseSharePermissions(share),
91
+ mode: share.mode,
92
+ invited_emails: parseInvitedEmails(share.invitedEmails),
93
+ expires_at: share.expiresAt?.toISOString() ?? null,
94
+ },
95
+ version: Date.now(),
96
+ view,
97
+ }),
98
+ });
99
+ }
100
+ catch {
101
+ throw new SyncUnavailableError();
102
+ }
103
+ if (response.status === 401) {
104
+ throw new SyncUnauthorizedError();
105
+ }
106
+ if (!response.ok) {
107
+ throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
108
+ }
109
+ }
110
+ function createWatchPush(push, projectId, remote, opts) {
111
+ const debounceMs = opts?.debounceMs ?? 1500;
112
+ const onPushError = opts?.onPushError ??
113
+ ((err) => {
114
+ const message = err instanceof Error ? err.message : String(err);
115
+ console.error(`sync watch push failed: ${message}`);
116
+ });
117
+ let timer;
118
+ let disposed = false;
119
+ const runPush = () => {
120
+ timer = undefined;
121
+ void push(projectId, remote).catch(onPushError);
122
+ };
123
+ return {
124
+ onChange() {
125
+ if (disposed) {
126
+ return;
127
+ }
128
+ if (timer !== undefined) {
129
+ clearTimeout(timer);
130
+ }
131
+ timer = setTimeout(runPush, debounceMs);
132
+ },
133
+ dispose() {
134
+ disposed = true;
135
+ if (timer !== undefined) {
136
+ clearTimeout(timer);
137
+ timer = undefined;
138
+ }
139
+ },
140
+ };
141
+ }
142
+ export function createSyncService(deps) {
143
+ const { db, eventBus, taskService, shareService } = deps;
144
+ return {
145
+ async push(projectId, remote) {
146
+ const shares = shareService.listShares(projectId);
147
+ if (shares === undefined) {
148
+ throw new SyncUnavailableError('project not found');
149
+ }
150
+ const active = shares.filter((share) => share.revoked_at === null);
151
+ let pushed = 0;
152
+ for (const serialized of active) {
153
+ const shareRow = getShare(db, serialized.id);
154
+ if (shareRow === undefined) {
155
+ continue;
156
+ }
157
+ const view = shareService.buildClientView(projectId, serialized.id);
158
+ if (view === undefined) {
159
+ continue;
160
+ }
161
+ await pushProjection(remote, shareRow, view);
162
+ pushed += 1;
163
+ }
164
+ return { pushed };
165
+ },
166
+ async publishProject(projectId, input) {
167
+ const globalProjectId = randomUUID();
168
+ const { pushed } = await this.push(projectId, {
169
+ serverUrl: input.serverUrl,
170
+ globalProjectId,
171
+ syncToken: input.syncToken,
172
+ });
173
+ return { globalProjectId, pushed };
174
+ },
175
+ async pull(projectId, remote) {
176
+ const cursor = getPullCursor(db, projectId);
177
+ const base = remote.serverUrl.replace(/\/$/, '');
178
+ const url = new URL(`${base}/api/sync/v1/projects/${encodeURIComponent(remote.globalProjectId)}/submissions`);
179
+ if (cursor !== undefined) {
180
+ url.searchParams.set('since', cursor);
181
+ }
182
+ let response;
183
+ try {
184
+ response = await fetch(url, {
185
+ headers: { Authorization: `Bearer ${remote.syncToken}` },
186
+ });
187
+ }
188
+ catch {
189
+ throw new SyncUnavailableError();
190
+ }
191
+ if (response.status === 401) {
192
+ throw new SyncUnauthorizedError();
193
+ }
194
+ if (!response.ok) {
195
+ throw new SyncUnavailableError(`sync server returned ${String(response.status)}`);
196
+ }
197
+ const remoteSubmissions = (await response.json());
198
+ const now = new Date();
199
+ let pulled = 0;
200
+ let maxCreatedAt = cursor;
201
+ for (const submission of remoteSubmissions) {
202
+ const inserted = upsertSubmission(db, {
203
+ id: submission.id,
204
+ projectId,
205
+ hostedShareId: submission.share_id,
206
+ participantName: submission.participant.name,
207
+ title: submission.title,
208
+ body: submission.body,
209
+ severity: submission.severity,
210
+ taskRef: submission.task_ref,
211
+ status: 'pending',
212
+ createdAt: new Date(submission.created_at),
213
+ pulledAt: now,
214
+ });
215
+ if (inserted) {
216
+ pulled += 1;
217
+ }
218
+ if (maxCreatedAt === undefined || submission.created_at > maxCreatedAt) {
219
+ maxCreatedAt = submission.created_at;
220
+ }
221
+ }
222
+ if (maxCreatedAt !== undefined && maxCreatedAt !== cursor) {
223
+ setPullCursor(db, projectId, maxCreatedAt);
224
+ }
225
+ if (pulled > 0) {
226
+ eventBus.emit({ type: 'submissions_pulled', projectId });
227
+ }
228
+ return { pulled };
229
+ },
230
+ listTriage(projectId, status) {
231
+ return listSubmissions(db, projectId, status).map(serializeSubmission);
232
+ },
233
+ getSubmission(submissionId) {
234
+ const submission = dbGetSubmission(db, submissionId);
235
+ return submission === undefined ? undefined : serializeSubmission(submission);
236
+ },
237
+ setRemote(projectId, remote) {
238
+ setSyncRemote(db, projectId, {
239
+ serverUrl: remote.serverUrl,
240
+ globalProjectId: remote.globalProjectId,
241
+ syncToken: remote.syncToken,
242
+ });
243
+ },
244
+ getRemote(projectId) {
245
+ const row = getSyncRemote(db, projectId);
246
+ if (row === undefined) {
247
+ return undefined;
248
+ }
249
+ return {
250
+ serverUrl: row.serverUrl,
251
+ globalProjectId: row.globalProjectId,
252
+ syncToken: row.syncToken,
253
+ };
254
+ },
255
+ async triage(submissionId, action, remote, asTask) {
256
+ const submission = dbGetSubmission(db, submissionId);
257
+ if (submission === undefined) {
258
+ throw new InvalidTriageError();
259
+ }
260
+ if (submission.status !== 'pending') {
261
+ return serializeSubmission(submission);
262
+ }
263
+ const projectId = submission.projectId;
264
+ if (action === 'accept') {
265
+ const task = taskService.create(projectId, {
266
+ label: asTask?.label ?? submission.title,
267
+ status: asTask?.status ?? 'todo',
268
+ description: asTask?.description ?? buildDesc(submission),
269
+ });
270
+ if (task === undefined) {
271
+ throw new InvalidTriageError('project not found');
272
+ }
273
+ const updated = setSubmissionStatus(db, submissionId, {
274
+ status: 'accepted',
275
+ linkedTaskId: task.id,
276
+ });
277
+ if (updated === undefined) {
278
+ throw new InvalidTriageError();
279
+ }
280
+ eventBus.emit({ type: 'submissions_pulled', projectId });
281
+ await ackSubmission(remote, submissionId, 'accepted');
282
+ return serializeSubmission(updated);
283
+ }
284
+ const updated = setSubmissionStatus(db, submissionId, { status: 'rejected' });
285
+ if (updated === undefined) {
286
+ throw new InvalidTriageError();
287
+ }
288
+ eventBus.emit({ type: 'submissions_pulled', projectId });
289
+ await ackSubmission(remote, submissionId, 'rejected');
290
+ return serializeSubmission(updated);
291
+ },
292
+ watchPush(projectId, remote, opts) {
293
+ return createWatchPush((id, rem) => this.push(id, rem), projectId, remote, opts);
294
+ },
295
+ };
296
+ }
297
+ //# sourceMappingURL=sync.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plandesk/api",
3
- "version": "0.3.0",
3
+ "version": "0.4.1",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -12,14 +12,14 @@
12
12
  "dist",
13
13
  "web",
14
14
  "!dist/**/*.test.js",
15
- "!dist/**/*.test.js.map",
16
15
  "!dist/**/*.test.d.ts",
17
- "!dist/**/*.test.d.ts.map"
16
+ "!dist/**/*.map"
18
17
  ],
19
18
  "devDependencies": {
20
19
  "typescript": "^6.0.3",
21
20
  "vitest": "^3.2.6",
22
- "@plandesk/db": "0.3.0"
21
+ "@plandesk/db": "0.4.1",
22
+ "@plandesk/sync-server": "0.4.1"
23
23
  },
24
24
  "dependencies": {
25
25
  "@hono/node-server": "^2.0.4",
@@ -28,7 +28,7 @@
28
28
  "description": "Plan Desk REST + SSE API and service layer (local-first planning workspace).",
29
29
  "license": "MIT",
30
30
  "author": "asyncdotengineering",
31
- "homepage": "https://plandesk-docs.pages.dev",
31
+ "homepage": "https://plandesk.asyncdot.com",
32
32
  "repository": {
33
33
  "type": "git",
34
34
  "url": "git+https://github.com/asyncdotengineering/plandesk.git",
package/web/_redirects ADDED
@@ -0,0 +1 @@
1
+ /* /index.html 200