@ketrics/ketrics-cli 0.5.0 → 0.6.0
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/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +6 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/commands/create.d.ts +1 -0
- package/dist/src/commands/create.d.ts.map +1 -1
- package/dist/src/commands/create.js +44 -13
- package/dist/src/commands/create.js.map +1 -1
- package/dist/src/services/local-template-service.d.ts +52 -0
- package/dist/src/services/local-template-service.d.ts.map +1 -0
- package/dist/src/services/local-template-service.js +216 -0
- package/dist/src/services/local-template-service.js.map +1 -0
- package/dist/src/services/remote-template-service.d.ts +41 -0
- package/dist/src/services/remote-template-service.d.ts.map +1 -0
- package/dist/src/services/remote-template-service.js +232 -0
- package/dist/src/services/remote-template-service.js.map +1 -0
- package/dist/src/services/template-cache-service.d.ts +44 -0
- package/dist/src/services/template-cache-service.d.ts.map +1 -0
- package/dist/src/services/template-cache-service.js +193 -0
- package/dist/src/services/template-cache-service.js.map +1 -0
- package/dist/src/services/template-service.d.ts +25 -31
- package/dist/src/services/template-service.d.ts.map +1 -1
- package/dist/src/services/template-service.js +136 -132
- package/dist/src/services/template-service.js.map +1 -1
- package/dist/src/types/index.d.ts +46 -0
- package/dist/src/types/index.d.ts.map +1 -1
- package/dist/src/types/index.js.map +1 -1
- package/dist/src/version.d.ts +1 -1
- package/dist/src/version.js +1 -1
- package/package.json +5 -1
- package/templates/HelloWorld/.claude/skills/ketrics-app/BACKEND_REFERENCE.md +693 -0
- package/templates/HelloWorld/.claude/skills/ketrics-app/CONFIG_AND_DEPLOY.md +278 -0
- package/templates/HelloWorld/.claude/skills/ketrics-app/FRONTEND_REFERENCE.md +325 -0
- package/templates/HelloWorld/.claude/skills/ketrics-app/SKILL.md +348 -0
- package/templates/HelloWorld/.env.example +20 -0
- package/templates/HelloWorld/.github/workflows/deploy.yml +51 -0
- package/templates/HelloWorld/backend/package.json +1 -1
|
@@ -0,0 +1,693 @@
|
|
|
1
|
+
# Backend SDK Reference
|
|
2
|
+
|
|
3
|
+
Complete reference for the `ketrics` global object and backend handler patterns. The `ketrics` object is automatically available in all backend handlers — no imports needed. Types come from `@ketrics/sdk-backend` (devDependency).
|
|
4
|
+
|
|
5
|
+
## Handler function pattern
|
|
6
|
+
|
|
7
|
+
Every handler is an async function that takes a typed payload and returns a result. Handlers are exported by name and must match the `actions` array in `ketrics.config.json`.
|
|
8
|
+
|
|
9
|
+
```typescript
|
|
10
|
+
interface MyPayload {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const myHandler = async (payload: MyPayload) => {
|
|
16
|
+
requireEditor(); // permission check
|
|
17
|
+
const { id, name } = payload;
|
|
18
|
+
|
|
19
|
+
// Validate input
|
|
20
|
+
if (!id) throw new Error("id is required");
|
|
21
|
+
if (!name?.trim()) throw new Error("name is required");
|
|
22
|
+
|
|
23
|
+
// Get requestor context
|
|
24
|
+
const userId = ketrics.requestor.userId;
|
|
25
|
+
if (!userId) throw new Error("User context is required");
|
|
26
|
+
|
|
27
|
+
// ... handler logic ...
|
|
28
|
+
|
|
29
|
+
return { item: { id, name } };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export { myHandler };
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## ketrics.environment
|
|
36
|
+
|
|
37
|
+
Read application environment variables configured in the Ketrics dashboard.
|
|
38
|
+
|
|
39
|
+
```typescript
|
|
40
|
+
// Simple string value
|
|
41
|
+
const apiKey = ketrics.environment["API_KEY"];
|
|
42
|
+
|
|
43
|
+
// JSON-encoded config
|
|
44
|
+
const raw = ketrics.environment["DB_CONNECTIONS"];
|
|
45
|
+
const connections: { code: string; name: string }[] = JSON.parse(raw);
|
|
46
|
+
|
|
47
|
+
// With fallback
|
|
48
|
+
const docDbCode = ketrics.environment["DOCDB_CODE"] || "default-store";
|
|
49
|
+
|
|
50
|
+
// Volume reference
|
|
51
|
+
const volumeCode = ketrics.environment["EXPORTS_VOLUME"];
|
|
52
|
+
if (!volumeCode) throw new Error("EXPORTS_VOLUME not configured");
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
Common environment variables:
|
|
56
|
+
- `DB_CONNECTIONS` — JSON array of `{code, name}` for database connection dropdown
|
|
57
|
+
- `DOCDB_*` — DocumentDB resource codes
|
|
58
|
+
- `EXPORTS_VOLUME` — Volume code for file exports
|
|
59
|
+
|
|
60
|
+
## ketrics.requestor
|
|
61
|
+
|
|
62
|
+
Context about the authenticated user making the request.
|
|
63
|
+
|
|
64
|
+
```typescript
|
|
65
|
+
ketrics.requestor.userId // string — User ID
|
|
66
|
+
ketrics.requestor.name // string — Display name
|
|
67
|
+
ketrics.requestor.email // string — Email address
|
|
68
|
+
ketrics.requestor.applicationPermissions // string[] — e.g., ["editor"]
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
### Permission checking pattern
|
|
72
|
+
|
|
73
|
+
```typescript
|
|
74
|
+
function requireEditor(): void {
|
|
75
|
+
if (!ketrics.requestor.applicationPermissions.includes("editor")) {
|
|
76
|
+
throw new Error("Permission denied: editor role required");
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Usage: call at the start of any handler that modifies data
|
|
81
|
+
const createItem = async (payload: CreatePayload) => {
|
|
82
|
+
requireEditor();
|
|
83
|
+
// ...
|
|
84
|
+
};
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## ketrics.Database
|
|
88
|
+
|
|
89
|
+
Connect to SQL databases configured for the tenant. Supports parameterized queries.
|
|
90
|
+
|
|
91
|
+
### Connect and query
|
|
92
|
+
|
|
93
|
+
```typescript
|
|
94
|
+
const db = await ketrics.Database.connect(connectionCode);
|
|
95
|
+
try {
|
|
96
|
+
const result = await db.query<Record<string, unknown>>(sql, params);
|
|
97
|
+
// result.rows: Record<string, unknown>[]
|
|
98
|
+
// result.rowCount: number
|
|
99
|
+
} finally {
|
|
100
|
+
await db.close(); // ALWAYS close in finally block
|
|
101
|
+
}
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
### Read-only enforcement
|
|
105
|
+
|
|
106
|
+
Block DML/DDL keywords to ensure read-only access:
|
|
107
|
+
|
|
108
|
+
```typescript
|
|
109
|
+
const FORBIDDEN_KEYWORDS = /\b(INSERT|UPDATE|DELETE|DROP|CREATE|ALTER|TRUNCATE)\b/i;
|
|
110
|
+
|
|
111
|
+
if (FORBIDDEN_KEYWORDS.test(sql)) {
|
|
112
|
+
throw new Error("Only SELECT queries are allowed.");
|
|
113
|
+
}
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
### Row limiting
|
|
117
|
+
|
|
118
|
+
Wrap queries to enforce server-side row limits:
|
|
119
|
+
|
|
120
|
+
```typescript
|
|
121
|
+
const MAX_ROWS = 500;
|
|
122
|
+
const limitedSql = `SELECT * FROM (${sql}) AS __limited_result LIMIT ${MAX_ROWS}`;
|
|
123
|
+
const result = await db.query<Record<string, unknown>>(limitedSql, params);
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### Parameterized queries (positional)
|
|
127
|
+
|
|
128
|
+
Use `$1`, `$2`, etc. for positional parameters:
|
|
129
|
+
|
|
130
|
+
```typescript
|
|
131
|
+
const sql = "SELECT * FROM users WHERE status = $1 AND role = $2";
|
|
132
|
+
const params = ["active", "admin"];
|
|
133
|
+
const result = await db.query<Record<string, unknown>>(sql, params);
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
### Template parameter replacement
|
|
137
|
+
|
|
138
|
+
Convert `{{paramName}}` placeholders to positional parameters:
|
|
139
|
+
|
|
140
|
+
```typescript
|
|
141
|
+
// Input: "SELECT * FROM users WHERE status = {{status}}"
|
|
142
|
+
// Output: "SELECT * FROM users WHERE status = $1" with values = ["active"]
|
|
143
|
+
|
|
144
|
+
let parameterizedSql = rawSql;
|
|
145
|
+
const values: unknown[] = [];
|
|
146
|
+
let paramIndex = 0;
|
|
147
|
+
|
|
148
|
+
// Detect all {{paramName}} placeholders
|
|
149
|
+
const paramNames: string[] = [];
|
|
150
|
+
const regex = /\{\{(\w+)\}\}/g;
|
|
151
|
+
let match;
|
|
152
|
+
while ((match = regex.exec(parameterizedSql)) !== null) {
|
|
153
|
+
if (!paramNames.includes(match[1])) paramNames.push(match[1]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Replace each with positional $N
|
|
157
|
+
for (const pName of paramNames) {
|
|
158
|
+
paramIndex++;
|
|
159
|
+
parameterizedSql = parameterizedSql.replace(
|
|
160
|
+
new RegExp(`\\{\\{${pName}\\}\\}`, "g"),
|
|
161
|
+
() => `$${paramIndex}`
|
|
162
|
+
);
|
|
163
|
+
values.push(paramValues[pName] ?? "");
|
|
164
|
+
}
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
### Identifier parameters (table/column names)
|
|
168
|
+
|
|
169
|
+
For dynamic table or column names, validate and interpolate directly (cannot use positional params):
|
|
170
|
+
|
|
171
|
+
```typescript
|
|
172
|
+
const VALID_IDENTIFIER = /^[a-zA-Z0-9_.\-]+$/;
|
|
173
|
+
|
|
174
|
+
if (param.isIdentifier) {
|
|
175
|
+
if (!val || !VALID_IDENTIFIER.test(val)) {
|
|
176
|
+
throw new Error(`Invalid identifier: "${val}"`);
|
|
177
|
+
}
|
|
178
|
+
// Direct string interpolation (safe after validation)
|
|
179
|
+
sql = sql.replace(`{{${paramName}}}`, val);
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### Conditional SQL blocks
|
|
184
|
+
|
|
185
|
+
Support optional sections in SQL using `{{#if param}}...{{/if}}`:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
parameterizedSql = parameterizedSql.replace(
|
|
189
|
+
/\{\{#if\s+(\w+)\}\}([\s\S]*?)\{\{\/if\}\}/g,
|
|
190
|
+
(_, condParam: string, content: string) => {
|
|
191
|
+
const val = paramValues[condParam];
|
|
192
|
+
return (val != null && val.trim() !== "") ? content : "";
|
|
193
|
+
}
|
|
194
|
+
);
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
Example SQL:
|
|
198
|
+
```sql
|
|
199
|
+
SELECT * FROM orders
|
|
200
|
+
WHERE status = {{status}}
|
|
201
|
+
{{#if date_from}} AND created_at >= {{date_from}} {{/if}}
|
|
202
|
+
{{#if date_to}} AND created_at <= {{date_to}} {{/if}}
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## ketrics.DocumentDb
|
|
206
|
+
|
|
207
|
+
NoSQL document store with DynamoDB-style partition key (pk) and sort key (sk).
|
|
208
|
+
|
|
209
|
+
### Connect
|
|
210
|
+
|
|
211
|
+
```typescript
|
|
212
|
+
const docdb = await ketrics.DocumentDb.connect(resourceCode);
|
|
213
|
+
// resourceCode matches the "code" in ketrics.config.json resources.documentdb
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Put (create or overwrite)
|
|
217
|
+
|
|
218
|
+
```typescript
|
|
219
|
+
const item: Record<string, unknown> = {
|
|
220
|
+
id: crypto.randomUUID(),
|
|
221
|
+
name: "My Item",
|
|
222
|
+
createdBy: userId,
|
|
223
|
+
createdAt: new Date().toISOString(),
|
|
224
|
+
updatedAt: new Date().toISOString(),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
await docdb.put(`USER#${userId}`, `ITEM#${item.id}`, item);
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
### Get (single item)
|
|
231
|
+
|
|
232
|
+
```typescript
|
|
233
|
+
const item = await docdb.get(`USER#${userId}`, `ITEM#${itemId}`);
|
|
234
|
+
if (!item) throw new Error("Not found");
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
### List (query by pk with optional sk prefix)
|
|
238
|
+
|
|
239
|
+
```typescript
|
|
240
|
+
const result = await docdb.list(`USER#${userId}`, {
|
|
241
|
+
skPrefix: "ITEM#", // Filter by sort key prefix
|
|
242
|
+
scanForward: false, // Reverse chronological order
|
|
243
|
+
limit: 100, // Max items to return
|
|
244
|
+
});
|
|
245
|
+
// result.items: Record<string, unknown>[]
|
|
246
|
+
```
|
|
247
|
+
|
|
248
|
+
### Delete
|
|
249
|
+
|
|
250
|
+
```typescript
|
|
251
|
+
await docdb.delete(`USER#${userId}`, `ITEM#${itemId}`);
|
|
252
|
+
```
|
|
253
|
+
|
|
254
|
+
### Key design patterns
|
|
255
|
+
|
|
256
|
+
Design composite keys with prefixes for multi-entity storage in a single DocumentDB:
|
|
257
|
+
|
|
258
|
+
**User-scoped items (each user owns their items):**
|
|
259
|
+
```typescript
|
|
260
|
+
pk = `USER#${userId}`
|
|
261
|
+
sk = `ITEM#${itemId}`
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
**Tenant-wide items (shared across all users):**
|
|
265
|
+
```typescript
|
|
266
|
+
pk = `TENANT_ITEMS`
|
|
267
|
+
sk = `ITEM#${itemId}`
|
|
268
|
+
```
|
|
269
|
+
|
|
270
|
+
**Comments on a target entity:**
|
|
271
|
+
```typescript
|
|
272
|
+
pk = `COMMENTS#${connectionCode}#${targetKey}`
|
|
273
|
+
sk = `COMMENT#${createdAt}#${commentId}`
|
|
274
|
+
// Using createdAt in sk gives chronological ordering
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Count index for fast lookups:**
|
|
278
|
+
```typescript
|
|
279
|
+
pk = `INDEX#${scope}`
|
|
280
|
+
sk = `KEY#${lookupKey}`
|
|
281
|
+
// Store: { lookupKey, count: N, lastUpdatedAt }
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
### CRUD handler template
|
|
285
|
+
|
|
286
|
+
```typescript
|
|
287
|
+
// CREATE
|
|
288
|
+
const createItem = async (payload: CreatePayload) => {
|
|
289
|
+
requireEditor();
|
|
290
|
+
const { name } = payload;
|
|
291
|
+
if (!name?.trim()) throw new Error("name is required");
|
|
292
|
+
|
|
293
|
+
const userId = ketrics.requestor.userId;
|
|
294
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
295
|
+
const itemId = crypto.randomUUID();
|
|
296
|
+
const now = new Date().toISOString();
|
|
297
|
+
|
|
298
|
+
const item = { id: itemId, name: name.trim(), createdBy: userId, createdAt: now, updatedAt: now };
|
|
299
|
+
await docdb.put(`USER#${userId}`, `ITEM#${itemId}`, item);
|
|
300
|
+
return { item };
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
// READ (single)
|
|
304
|
+
const getItem = async (payload: { id: string }) => {
|
|
305
|
+
const { id } = payload;
|
|
306
|
+
if (!id) throw new Error("id is required");
|
|
307
|
+
|
|
308
|
+
const userId = ketrics.requestor.userId;
|
|
309
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
310
|
+
const item = await docdb.get(`USER#${userId}`, `ITEM#${id}`);
|
|
311
|
+
if (!item) throw new Error("Not found");
|
|
312
|
+
if (item.createdBy !== userId) throw new Error("Access denied");
|
|
313
|
+
return { item };
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
// READ (list)
|
|
317
|
+
const listItems = async () => {
|
|
318
|
+
const userId = ketrics.requestor.userId;
|
|
319
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
320
|
+
const result = await docdb.list(`USER#${userId}`, { skPrefix: "ITEM#", scanForward: false });
|
|
321
|
+
return { items: result.items };
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
// UPDATE
|
|
325
|
+
const updateItem = async (payload: UpdatePayload) => {
|
|
326
|
+
requireEditor();
|
|
327
|
+
const { id, name } = payload;
|
|
328
|
+
if (!id) throw new Error("id is required");
|
|
329
|
+
|
|
330
|
+
const userId = ketrics.requestor.userId;
|
|
331
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
332
|
+
const pk = `USER#${userId}`;
|
|
333
|
+
const sk = `ITEM#${id}`;
|
|
334
|
+
|
|
335
|
+
const existing = await docdb.get(pk, sk);
|
|
336
|
+
if (!existing) throw new Error("Not found");
|
|
337
|
+
if (existing.createdBy !== userId) throw new Error("You can only update your own items");
|
|
338
|
+
|
|
339
|
+
const item = { ...existing, name: name.trim(), updatedAt: new Date().toISOString() };
|
|
340
|
+
await docdb.put(pk, sk, item);
|
|
341
|
+
return { item };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
// DELETE
|
|
345
|
+
const deleteItem = async (payload: { id: string }) => {
|
|
346
|
+
requireEditor();
|
|
347
|
+
const { id } = payload;
|
|
348
|
+
if (!id) throw new Error("id is required");
|
|
349
|
+
|
|
350
|
+
const userId = ketrics.requestor.userId;
|
|
351
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
352
|
+
const pk = `USER#${userId}`;
|
|
353
|
+
const sk = `ITEM#${id}`;
|
|
354
|
+
|
|
355
|
+
const existing = await docdb.get(pk, sk);
|
|
356
|
+
if (!existing) throw new Error("Not found");
|
|
357
|
+
if (existing.createdBy !== userId) throw new Error("You can only delete your own items");
|
|
358
|
+
|
|
359
|
+
await docdb.delete(pk, sk);
|
|
360
|
+
return { success: true };
|
|
361
|
+
};
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
## ketrics.Excel
|
|
365
|
+
|
|
366
|
+
Create Excel workbooks in memory.
|
|
367
|
+
|
|
368
|
+
```typescript
|
|
369
|
+
const excel = ketrics.Excel.create();
|
|
370
|
+
const sheet = excel.addWorksheet("Sheet Name");
|
|
371
|
+
|
|
372
|
+
// Set columns with headers and widths
|
|
373
|
+
sheet.columns = [
|
|
374
|
+
{ header: "Name", key: "name", width: 20 },
|
|
375
|
+
{ header: "Email", key: "email", width: 30 },
|
|
376
|
+
{ header: "Status", key: "status", width: 15 },
|
|
377
|
+
];
|
|
378
|
+
|
|
379
|
+
// Add rows (array of arrays, matching column order)
|
|
380
|
+
sheet.addRows(
|
|
381
|
+
data.map(row => [row.name, row.email, row.status])
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
// Get buffer for saving to Volume
|
|
385
|
+
const buffer = await excel.toBuffer();
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Dynamic column widths
|
|
389
|
+
|
|
390
|
+
```typescript
|
|
391
|
+
sheet.columns = columns.map(col => ({
|
|
392
|
+
header: col,
|
|
393
|
+
key: col,
|
|
394
|
+
width: Math.max(12, col.length + 4),
|
|
395
|
+
}));
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## ketrics.Volume
|
|
399
|
+
|
|
400
|
+
File storage with presigned download URLs.
|
|
401
|
+
|
|
402
|
+
```typescript
|
|
403
|
+
const volume = await ketrics.Volume.connect(volumeCode);
|
|
404
|
+
|
|
405
|
+
// Upload a file
|
|
406
|
+
const fileKey = `${ketrics.application.id}/${filename}`;
|
|
407
|
+
await volume.put(fileKey, buffer);
|
|
408
|
+
|
|
409
|
+
// Generate download URL
|
|
410
|
+
const presigned = await volume.generateDownloadUrl(fileKey);
|
|
411
|
+
// presigned.url — time-limited download URL
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### Complete export pattern (Excel + Volume)
|
|
415
|
+
|
|
416
|
+
```typescript
|
|
417
|
+
const exportData = async (payload: { data: Record<string, unknown>[] }) => {
|
|
418
|
+
requireEditor();
|
|
419
|
+
const { data } = payload;
|
|
420
|
+
if (!data?.length) throw new Error("No data to export");
|
|
421
|
+
|
|
422
|
+
const volumeCode = ketrics.environment["EXPORTS_VOLUME"];
|
|
423
|
+
if (!volumeCode) throw new Error("EXPORTS_VOLUME not configured");
|
|
424
|
+
|
|
425
|
+
const columns = Object.keys(data[0]);
|
|
426
|
+
|
|
427
|
+
// Build Excel
|
|
428
|
+
const excel = ketrics.Excel.create();
|
|
429
|
+
const sheet = excel.addWorksheet("Export");
|
|
430
|
+
sheet.columns = columns.map(col => ({
|
|
431
|
+
header: col,
|
|
432
|
+
key: col,
|
|
433
|
+
width: Math.max(12, col.length + 4),
|
|
434
|
+
}));
|
|
435
|
+
sheet.addRows(data.map(row => columns.map(col => row[col] ?? "")));
|
|
436
|
+
const buffer = await excel.toBuffer();
|
|
437
|
+
|
|
438
|
+
// Save to Volume
|
|
439
|
+
const filename = `export_${Date.now()}.xlsx`;
|
|
440
|
+
const fileKey = `${ketrics.application.id}/${filename}`;
|
|
441
|
+
const volume = await ketrics.Volume.connect(volumeCode);
|
|
442
|
+
await volume.put(fileKey, buffer);
|
|
443
|
+
const { url } = await volume.generateDownloadUrl(fileKey);
|
|
444
|
+
|
|
445
|
+
return { url, filename };
|
|
446
|
+
};
|
|
447
|
+
```
|
|
448
|
+
|
|
449
|
+
## ketrics.Messages
|
|
450
|
+
|
|
451
|
+
Send notifications to users in the tenant.
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// Bulk send to specific users
|
|
455
|
+
await ketrics.Messages.sendBulk({
|
|
456
|
+
userIds: ["user-id-1", "user-id-2"],
|
|
457
|
+
type: "CUSTOM_EVENT_TYPE",
|
|
458
|
+
subject: "Notification subject",
|
|
459
|
+
body: "**Markdown** content is supported in the body.",
|
|
460
|
+
priority: "MEDIUM", // "LOW" | "MEDIUM" | "HIGH"
|
|
461
|
+
});
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
### Notification on share pattern
|
|
465
|
+
|
|
466
|
+
```typescript
|
|
467
|
+
if (visibility === "shared" && Array.isArray(sharedWith) && sharedWith.length > 0) {
|
|
468
|
+
const senderName = ketrics.requestor.name || ketrics.requestor.email;
|
|
469
|
+
try {
|
|
470
|
+
await ketrics.Messages.sendBulk({
|
|
471
|
+
userIds: sharedWith,
|
|
472
|
+
type: "RESOURCE_SHARED",
|
|
473
|
+
subject: `${senderName} shared a resource with you`,
|
|
474
|
+
body: `**${senderName}** shared **${resourceName}** with you.`,
|
|
475
|
+
priority: "MEDIUM",
|
|
476
|
+
});
|
|
477
|
+
} catch {
|
|
478
|
+
// Non-critical — don't fail the main operation
|
|
479
|
+
ketrics.console.error("Failed to send share notifications");
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
## ketrics.Users
|
|
485
|
+
|
|
486
|
+
List users in the current tenant.
|
|
487
|
+
|
|
488
|
+
```typescript
|
|
489
|
+
const tenantUsers = await ketrics.Users.list();
|
|
490
|
+
// Returns: { id: string, firstName: string, lastName: string, email: string }[]
|
|
491
|
+
|
|
492
|
+
const users = tenantUsers.map(user => ({
|
|
493
|
+
userId: user.id,
|
|
494
|
+
name: `${user.firstName} ${user.lastName}`.trim(),
|
|
495
|
+
email: user.email,
|
|
496
|
+
}));
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
## ketrics.application
|
|
500
|
+
|
|
501
|
+
```typescript
|
|
502
|
+
ketrics.application.id // Application UUID, useful for namespacing Volume file keys
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
## ketrics.console
|
|
506
|
+
|
|
507
|
+
```typescript
|
|
508
|
+
ketrics.console.error("Error message for logs");
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
Use for non-critical errors where you don't want to throw and fail the handler.
|
|
512
|
+
|
|
513
|
+
## Comment system pattern
|
|
514
|
+
|
|
515
|
+
A full comment system using DocumentDB with a count index for efficient lookups.
|
|
516
|
+
|
|
517
|
+
### Add comment
|
|
518
|
+
|
|
519
|
+
```typescript
|
|
520
|
+
const addComment = async (payload: { targetId: string; text: string }) => {
|
|
521
|
+
const { targetId, text } = payload;
|
|
522
|
+
if (!targetId) throw new Error("targetId is required");
|
|
523
|
+
if (!text?.trim()) throw new Error("text is required");
|
|
524
|
+
|
|
525
|
+
const userId = ketrics.requestor.userId;
|
|
526
|
+
const userName = ketrics.requestor.name || ketrics.requestor.email || userId;
|
|
527
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
528
|
+
const commentId = crypto.randomUUID();
|
|
529
|
+
const now = new Date().toISOString();
|
|
530
|
+
|
|
531
|
+
const comment = {
|
|
532
|
+
commentId,
|
|
533
|
+
text: text.trim(),
|
|
534
|
+
targetId,
|
|
535
|
+
createdBy: userId,
|
|
536
|
+
createdByName: userName,
|
|
537
|
+
createdAt: now,
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
// Store comment (chronologically sorted by sk)
|
|
541
|
+
await docdb.put(
|
|
542
|
+
`COMMENTS#${targetId}`,
|
|
543
|
+
`COMMENT#${now}#${commentId}`,
|
|
544
|
+
comment as Record<string, unknown>
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
// Update count index
|
|
548
|
+
const indexPk = `COMMENTINDEX#main`;
|
|
549
|
+
const indexSk = `TARGET#${targetId}`;
|
|
550
|
+
const existing = await docdb.get(indexPk, indexSk);
|
|
551
|
+
const count = existing ? (existing.count as number) || 0 : 0;
|
|
552
|
+
await docdb.put(indexPk, indexSk, { targetId, count: count + 1, lastCommentAt: now });
|
|
553
|
+
|
|
554
|
+
return { comment };
|
|
555
|
+
};
|
|
556
|
+
```
|
|
557
|
+
|
|
558
|
+
### List comments
|
|
559
|
+
|
|
560
|
+
```typescript
|
|
561
|
+
const getComments = async (payload: { targetId: string }) => {
|
|
562
|
+
const { targetId } = payload;
|
|
563
|
+
if (!targetId) throw new Error("targetId is required");
|
|
564
|
+
|
|
565
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
566
|
+
const result = await docdb.list(`COMMENTS#${targetId}`, {
|
|
567
|
+
skPrefix: "COMMENT#",
|
|
568
|
+
scanForward: false, // Newest first
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
return { comments: result.items };
|
|
572
|
+
};
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
### Get comment counts (batch)
|
|
576
|
+
|
|
577
|
+
```typescript
|
|
578
|
+
const getCommentCounts = async (payload: { targetIds: string[] }) => {
|
|
579
|
+
const { targetIds } = payload;
|
|
580
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
581
|
+
const result = await docdb.list(`COMMENTINDEX#main`, {
|
|
582
|
+
skPrefix: "TARGET#",
|
|
583
|
+
limit: 1000,
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
const counts: Record<string, number> = {};
|
|
587
|
+
const requested = new Set(targetIds || []);
|
|
588
|
+
for (const item of result.items) {
|
|
589
|
+
const key = item.targetId as string;
|
|
590
|
+
if (key && (requested.size === 0 || requested.has(key))) {
|
|
591
|
+
counts[key] = (item.count as number) || 0;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
return { counts };
|
|
595
|
+
};
|
|
596
|
+
```
|
|
597
|
+
|
|
598
|
+
### Delete comment (with index update)
|
|
599
|
+
|
|
600
|
+
```typescript
|
|
601
|
+
const deleteComment = async (payload: { targetId: string; commentId: string; createdAt: string }) => {
|
|
602
|
+
const { targetId, commentId, createdAt } = payload;
|
|
603
|
+
const userId = ketrics.requestor.userId;
|
|
604
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
605
|
+
|
|
606
|
+
const pk = `COMMENTS#${targetId}`;
|
|
607
|
+
const sk = `COMMENT#${createdAt}#${commentId}`;
|
|
608
|
+
|
|
609
|
+
const existing = await docdb.get(pk, sk);
|
|
610
|
+
if (!existing) throw new Error("Comment not found");
|
|
611
|
+
if (existing.createdBy !== userId) throw new Error("You can only delete your own comments");
|
|
612
|
+
|
|
613
|
+
await docdb.delete(pk, sk);
|
|
614
|
+
|
|
615
|
+
// Decrement count index
|
|
616
|
+
const indexPk = `COMMENTINDEX#main`;
|
|
617
|
+
const indexSk = `TARGET#${targetId}`;
|
|
618
|
+
const indexItem = await docdb.get(indexPk, indexSk);
|
|
619
|
+
if (indexItem) {
|
|
620
|
+
const count = (indexItem.count as number) || 0;
|
|
621
|
+
if (count <= 1) {
|
|
622
|
+
await docdb.delete(indexPk, indexSk);
|
|
623
|
+
} else {
|
|
624
|
+
await docdb.put(indexPk, indexSk, { targetId, count: count - 1, lastCommentAt: indexItem.lastCommentAt });
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return { success: true };
|
|
629
|
+
};
|
|
630
|
+
```
|
|
631
|
+
|
|
632
|
+
## Sharing and access control pattern
|
|
633
|
+
|
|
634
|
+
For resources that can be shared between users:
|
|
635
|
+
|
|
636
|
+
```typescript
|
|
637
|
+
interface SharedResource {
|
|
638
|
+
id: string;
|
|
639
|
+
owner: string;
|
|
640
|
+
visibility: "private" | "shared";
|
|
641
|
+
sharedWith: string[] | "all";
|
|
642
|
+
// ... other fields
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Share handler
|
|
646
|
+
const shareResource = async (payload: { id: string; visibility: "private" | "shared"; sharedWith: string[] | "all" }) => {
|
|
647
|
+
requireEditor();
|
|
648
|
+
const { id, visibility, sharedWith } = payload;
|
|
649
|
+
const userId = ketrics.requestor.userId;
|
|
650
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
651
|
+
|
|
652
|
+
const existing = await docdb.get("TENANT_RESOURCES", `RESOURCE#${id}`);
|
|
653
|
+
if (!existing) throw new Error("Not found");
|
|
654
|
+
if (existing.owner !== userId) throw new Error("Only the owner can share");
|
|
655
|
+
|
|
656
|
+
await docdb.put("TENANT_RESOURCES", `RESOURCE#${id}`, {
|
|
657
|
+
...existing,
|
|
658
|
+
visibility: visibility || "private",
|
|
659
|
+
sharedWith: sharedWith || [],
|
|
660
|
+
updatedAt: new Date().toISOString(),
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
return { success: true };
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
// Access check helper
|
|
667
|
+
function checkAccess(item: Record<string, unknown>, userId: string): void {
|
|
668
|
+
if (item.owner === userId) return;
|
|
669
|
+
if (item.visibility !== "shared") throw new Error("Access denied");
|
|
670
|
+
const sw = item.sharedWith;
|
|
671
|
+
if (sw === "all") return;
|
|
672
|
+
if (Array.isArray(sw) && sw.includes(userId)) return;
|
|
673
|
+
throw new Error("Access denied");
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// List with visibility filter
|
|
677
|
+
const listResources = async () => {
|
|
678
|
+
const userId = ketrics.requestor.userId;
|
|
679
|
+
const docdb = await ketrics.DocumentDb.connect(getDocDbCode());
|
|
680
|
+
const result = await docdb.list("TENANT_RESOURCES", { skPrefix: "RESOURCE#", scanForward: false });
|
|
681
|
+
|
|
682
|
+
const resources = result.items.filter(item => {
|
|
683
|
+
if (item.owner === userId) return true;
|
|
684
|
+
if (item.visibility !== "shared") return false;
|
|
685
|
+
const sw = item.sharedWith;
|
|
686
|
+
if (sw === "all") return true;
|
|
687
|
+
if (Array.isArray(sw) && sw.includes(userId)) return true;
|
|
688
|
+
return false;
|
|
689
|
+
});
|
|
690
|
+
|
|
691
|
+
return { resources };
|
|
692
|
+
};
|
|
693
|
+
```
|