@contractspec/lib.runtime-sandbox 2.7.6 → 2.7.9
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/README.md +42 -22
- package/dist/browser/index.js +364 -120
- package/dist/index.js +364 -120
- package/dist/node/index.js +364 -120
- package/dist/web/index.d.ts +3 -3
- package/package.json +7 -7
- package/src/adapters/pglite/adapter.ts +132 -132
- package/src/index.ts +9 -9
- package/src/ports/database.port.ts +53 -53
- package/src/ports/index.ts +2 -2
- package/src/types/database.types.ts +22 -22
- package/src/web/database/migrations.ts +195 -195
- package/src/web/database/schema.ts +419 -419
- package/src/web/events/local-pubsub.ts +22 -22
- package/src/web/graphql/local-client.ts +495 -496
- package/src/web/index.ts +6 -11
- package/src/web/runtime/seeders/index.ts +611 -352
- package/src/web/runtime/services.ts +104 -105
- package/src/web/storage/indexeddb.ts +98 -98
- package/src/web/utils/id.ts +5 -5
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { ApolloClient, InMemoryCache } from '@apollo/client';
|
|
2
2
|
import { SchemaLink } from '@apollo/client/link/schema';
|
|
3
|
+
import type { DatabasePort, DbRow } from '@contractspec/lib.runtime-sandbox';
|
|
3
4
|
import { makeExecutableSchema } from '@graphql-tools/schema';
|
|
4
5
|
import { GraphQLScalarType, Kind } from 'graphql';
|
|
5
|
-
|
|
6
|
-
import type { DatabasePort, DbRow } from '@contractspec/lib.runtime-sandbox';
|
|
7
6
|
import { LocalEventBus } from '../events/local-pubsub';
|
|
8
7
|
import { LocalStorageService } from '../storage/indexeddb';
|
|
9
8
|
import { generateId } from '../utils/id';
|
|
@@ -190,9 +189,9 @@ const typeDefs = /* GraphQL */ `
|
|
|
190
189
|
`;
|
|
191
190
|
|
|
192
191
|
interface ResolverContext {
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
192
|
+
db: DatabasePort;
|
|
193
|
+
storage: LocalStorageService;
|
|
194
|
+
pubsub: LocalEventBus;
|
|
196
195
|
}
|
|
197
196
|
|
|
198
197
|
type ResolverParent = Record<string, unknown>;
|
|
@@ -203,170 +202,170 @@ type ResolverParent = Record<string, unknown>;
|
|
|
203
202
|
type LocalRow = DbRow;
|
|
204
203
|
|
|
205
204
|
const DateTimeScalar = new GraphQLScalarType({
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
205
|
+
name: 'DateTime',
|
|
206
|
+
parseValue(value: unknown) {
|
|
207
|
+
return value ? new Date(value as string).toISOString() : null;
|
|
208
|
+
},
|
|
209
|
+
serialize(value: unknown) {
|
|
210
|
+
if (!value) return null;
|
|
211
|
+
if (typeof value === 'string') return value;
|
|
212
|
+
return new Date(value as string).toISOString();
|
|
213
|
+
},
|
|
214
|
+
parseLiteral(ast) {
|
|
215
|
+
if (ast.kind === Kind.STRING) {
|
|
216
|
+
return new Date(ast.value).toISOString();
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
},
|
|
221
220
|
});
|
|
222
221
|
|
|
223
222
|
export interface LocalGraphQLClientOptions {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
223
|
+
db: DatabasePort;
|
|
224
|
+
storage: LocalStorageService;
|
|
225
|
+
pubsub?: LocalEventBus;
|
|
227
226
|
}
|
|
228
227
|
|
|
229
228
|
export class LocalGraphQLClient {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
229
|
+
readonly apollo: InstanceType<typeof ApolloClient>;
|
|
230
|
+
|
|
231
|
+
constructor(private readonly options: LocalGraphQLClientOptions) {
|
|
232
|
+
const schema = makeExecutableSchema({
|
|
233
|
+
typeDefs,
|
|
234
|
+
resolvers: this.createResolvers(),
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
this.apollo = new ApolloClient({
|
|
238
|
+
cache: new InMemoryCache(),
|
|
239
|
+
link: new SchemaLink({
|
|
240
|
+
schema,
|
|
241
|
+
context: () => ({
|
|
242
|
+
db: this.options.db,
|
|
243
|
+
storage: this.options.storage,
|
|
244
|
+
pubsub: this.options.pubsub ?? new LocalEventBus(),
|
|
245
|
+
}),
|
|
246
|
+
}),
|
|
247
|
+
devtools: {
|
|
248
|
+
enabled: typeof window !== 'undefined',
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
private createResolvers() {
|
|
254
|
+
return {
|
|
255
|
+
DateTime: DateTimeScalar,
|
|
256
|
+
Query: {
|
|
257
|
+
taskCategories: async (
|
|
258
|
+
_: ResolverParent,
|
|
259
|
+
args: { projectId: string },
|
|
260
|
+
ctx: ResolverContext
|
|
261
|
+
) => {
|
|
262
|
+
const result = await ctx.db.query(
|
|
263
|
+
`SELECT * FROM template_task_category WHERE "projectId" = $1 ORDER BY name ASC`,
|
|
264
|
+
[args.projectId]
|
|
265
|
+
);
|
|
266
|
+
return result.rows.map(mapTaskCategory);
|
|
267
|
+
},
|
|
268
|
+
tasks: async (
|
|
269
|
+
_: ResolverParent,
|
|
270
|
+
args: { projectId: string },
|
|
271
|
+
ctx: ResolverContext
|
|
272
|
+
) => {
|
|
273
|
+
const result = await ctx.db.query(
|
|
274
|
+
`SELECT * FROM template_task WHERE "projectId" = $1 ORDER BY "createdAt" DESC`,
|
|
275
|
+
[args.projectId]
|
|
276
|
+
);
|
|
277
|
+
return result.rows.map(mapTask);
|
|
278
|
+
},
|
|
279
|
+
conversations: async (
|
|
280
|
+
_: ResolverParent,
|
|
281
|
+
args: { projectId: string },
|
|
282
|
+
ctx: ResolverContext
|
|
283
|
+
) => {
|
|
284
|
+
const result = await ctx.db.query(
|
|
285
|
+
`SELECT * FROM template_conversation WHERE "projectId" = $1 ORDER BY "updatedAt" DESC`,
|
|
286
|
+
[args.projectId]
|
|
287
|
+
);
|
|
288
|
+
return result.rows.map(mapConversation);
|
|
289
|
+
},
|
|
290
|
+
messages: async (
|
|
291
|
+
_: ResolverParent,
|
|
292
|
+
args: { conversationId: string; limit: number },
|
|
293
|
+
ctx: ResolverContext
|
|
294
|
+
) => {
|
|
295
|
+
const result = await ctx.db.query(
|
|
296
|
+
`SELECT * FROM template_message WHERE "conversationId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
|
|
297
|
+
[args.conversationId, args.limit]
|
|
298
|
+
);
|
|
299
|
+
return result.rows.map(mapMessage);
|
|
300
|
+
},
|
|
301
|
+
recipes: async (
|
|
302
|
+
_: ResolverParent,
|
|
303
|
+
args: { projectId: string; locale: 'EN' | 'FR' },
|
|
304
|
+
ctx: ResolverContext
|
|
305
|
+
) => {
|
|
306
|
+
const result = await ctx.db.query(
|
|
307
|
+
`SELECT * FROM template_recipe WHERE "projectId" = $1 ORDER BY "nameEn" ASC`,
|
|
308
|
+
[args.projectId]
|
|
309
|
+
);
|
|
310
|
+
return result.rows.map((row: LocalRow) =>
|
|
311
|
+
mapRecipe(row, args.locale)
|
|
312
|
+
);
|
|
313
|
+
},
|
|
314
|
+
recipe: async (
|
|
315
|
+
_: ResolverParent,
|
|
316
|
+
args: { id: string; locale: 'EN' | 'FR' },
|
|
317
|
+
ctx: ResolverContext
|
|
318
|
+
) => {
|
|
319
|
+
const result = await ctx.db.query(
|
|
320
|
+
`SELECT * FROM template_recipe WHERE id = $1 LIMIT 1`,
|
|
321
|
+
[args.id]
|
|
322
|
+
);
|
|
323
|
+
if (!result.rows.length || !result.rows[0]) return null;
|
|
324
|
+
return mapRecipe(result.rows[0], args.locale);
|
|
325
|
+
},
|
|
326
|
+
},
|
|
327
|
+
Mutation: {
|
|
328
|
+
createTask: async (
|
|
329
|
+
_: ResolverParent,
|
|
330
|
+
args: { input: Record<string, unknown> },
|
|
331
|
+
ctx: ResolverContext
|
|
332
|
+
) => {
|
|
333
|
+
const id = generateId('task');
|
|
334
|
+
const now = new Date().toISOString();
|
|
335
|
+
const tags = JSON.stringify(args.input.tags ?? []);
|
|
336
|
+
await ctx.db.execute(
|
|
337
|
+
`INSERT INTO template_task (id, "projectId", "categoryId", title, description, completed, priority, "dueDate", tags, "createdAt", "updatedAt")
|
|
339
338
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)`,
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
339
|
+
[
|
|
340
|
+
id,
|
|
341
|
+
args.input.projectId as string,
|
|
342
|
+
(args.input.categoryId as string | undefined) ?? null,
|
|
343
|
+
args.input.title as string,
|
|
344
|
+
(args.input.description as string | undefined) ?? null,
|
|
345
|
+
0,
|
|
346
|
+
(args.input.priority as string | undefined) ?? 'MEDIUM',
|
|
347
|
+
(args.input.dueDate as string | undefined) ?? null,
|
|
348
|
+
tags,
|
|
349
|
+
now,
|
|
350
|
+
now,
|
|
351
|
+
]
|
|
352
|
+
);
|
|
353
|
+
const result = await ctx.db.query(
|
|
354
|
+
`SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
|
|
355
|
+
[id]
|
|
356
|
+
);
|
|
357
|
+
if (!result.rows.length || !result.rows[0])
|
|
358
|
+
throw new Error('Failed to create task');
|
|
359
|
+
return mapTask(result.rows[0]);
|
|
360
|
+
},
|
|
361
|
+
updateTask: async (
|
|
362
|
+
_: ResolverParent,
|
|
363
|
+
args: { id: string; input: Record<string, unknown> },
|
|
364
|
+
ctx: ResolverContext
|
|
365
|
+
) => {
|
|
366
|
+
const now = new Date().toISOString();
|
|
367
|
+
await ctx.db.execute(
|
|
368
|
+
`UPDATE template_task
|
|
370
369
|
SET "categoryId" = COALESCE($1, "categoryId"),
|
|
371
370
|
title = COALESCE($2, title),
|
|
372
371
|
description = COALESCE($3, description),
|
|
@@ -375,373 +374,373 @@ export class LocalGraphQLClient {
|
|
|
375
374
|
tags = COALESCE($6, tags),
|
|
376
375
|
"updatedAt" = $7
|
|
377
376
|
WHERE id = $8`,
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
377
|
+
[
|
|
378
|
+
(args.input.categoryId as string | undefined) ?? null,
|
|
379
|
+
(args.input.title as string | undefined) ?? null,
|
|
380
|
+
(args.input.description as string | undefined) ?? null,
|
|
381
|
+
(args.input.priority as string | undefined) ?? null,
|
|
382
|
+
(args.input.dueDate as string | undefined) ?? null,
|
|
383
|
+
args.input.tags ? JSON.stringify(args.input.tags) : null,
|
|
384
|
+
now,
|
|
385
|
+
args.id,
|
|
386
|
+
]
|
|
387
|
+
);
|
|
388
|
+
const result = await ctx.db.query(
|
|
389
|
+
`SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
|
|
390
|
+
[args.id]
|
|
391
|
+
);
|
|
392
|
+
if (!result.rows.length || !result.rows[0])
|
|
393
|
+
throw new Error('Task not found');
|
|
394
|
+
return mapTask(result.rows[0]);
|
|
395
|
+
},
|
|
396
|
+
toggleTask: async (
|
|
397
|
+
_: ResolverParent,
|
|
398
|
+
args: { id: string; completed: boolean },
|
|
399
|
+
ctx: ResolverContext
|
|
400
|
+
) => {
|
|
401
|
+
const now = new Date().toISOString();
|
|
402
|
+
await ctx.db.execute(
|
|
403
|
+
`UPDATE template_task SET completed = $1, "updatedAt" = $2 WHERE id = $3`,
|
|
404
|
+
[args.completed ? 1 : 0, now, args.id]
|
|
405
|
+
);
|
|
406
|
+
const result = await ctx.db.query(
|
|
407
|
+
`SELECT * FROM template_task WHERE id = $1 LIMIT 1`,
|
|
408
|
+
[args.id]
|
|
409
|
+
);
|
|
410
|
+
if (!result.rows.length || !result.rows[0])
|
|
411
|
+
throw new Error('Task not found');
|
|
412
|
+
return mapTask(result.rows[0]);
|
|
413
|
+
},
|
|
414
|
+
deleteTask: async (
|
|
415
|
+
_: ResolverParent,
|
|
416
|
+
args: { id: string },
|
|
417
|
+
ctx: ResolverContext
|
|
418
|
+
) => {
|
|
419
|
+
await ctx.db.execute(`DELETE FROM template_task WHERE id = $1`, [
|
|
420
|
+
args.id,
|
|
421
|
+
]);
|
|
422
|
+
return true;
|
|
423
|
+
},
|
|
424
|
+
createConversation: async (
|
|
425
|
+
_: ResolverParent,
|
|
426
|
+
args: { input: Record<string, unknown> },
|
|
427
|
+
ctx: ResolverContext
|
|
428
|
+
) => {
|
|
429
|
+
const id = generateId('conversation');
|
|
430
|
+
const now = new Date().toISOString();
|
|
431
|
+
await ctx.db.execute(
|
|
432
|
+
`INSERT INTO template_conversation (id, "projectId", name, "isGroup", "avatarUrl", "updatedAt")
|
|
434
433
|
VALUES ($1, $2, $3, $4, $5, $6)`,
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
434
|
+
[
|
|
435
|
+
id,
|
|
436
|
+
args.input.projectId as string,
|
|
437
|
+
(args.input.name as string | undefined) ?? null,
|
|
438
|
+
(args.input.isGroup as boolean | undefined) ? 1 : 0,
|
|
439
|
+
(args.input.avatarUrl as string | undefined) ?? null,
|
|
440
|
+
now,
|
|
441
|
+
]
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
const participants =
|
|
445
|
+
(args.input.participants as Record<string, string>[]) ?? [];
|
|
446
|
+
for (const participant of participants) {
|
|
447
|
+
await ctx.db.execute(
|
|
448
|
+
`INSERT INTO template_conversation_participant (id, "conversationId", "projectId", "userId", "displayName", role, "joinedAt")
|
|
450
449
|
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
450
|
+
[
|
|
451
|
+
generateId('participant'),
|
|
452
|
+
id,
|
|
453
|
+
args.input.projectId as string,
|
|
454
|
+
participant.userId,
|
|
455
|
+
participant.displayName ?? null,
|
|
456
|
+
participant.role ?? null,
|
|
457
|
+
now,
|
|
458
|
+
]
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
const result = await ctx.db.query(
|
|
463
|
+
`SELECT * FROM template_conversation WHERE id = $1 LIMIT 1`,
|
|
464
|
+
[id]
|
|
465
|
+
);
|
|
466
|
+
if (!result.rows.length || !result.rows[0])
|
|
467
|
+
throw new Error('Failed to create conversation');
|
|
468
|
+
return mapConversation(result.rows[0]);
|
|
469
|
+
},
|
|
470
|
+
sendMessage: async (
|
|
471
|
+
_: ResolverParent,
|
|
472
|
+
args: { input: Record<string, unknown> },
|
|
473
|
+
ctx: ResolverContext
|
|
474
|
+
) => {
|
|
475
|
+
const id = generateId('message');
|
|
476
|
+
const now = new Date().toISOString();
|
|
477
|
+
await ctx.db.execute(
|
|
478
|
+
`INSERT INTO template_message (id, "conversationId", "projectId", "senderId", "senderName", content, attachments, status, "createdAt", "updatedAt")
|
|
480
479
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
480
|
+
[
|
|
481
|
+
id,
|
|
482
|
+
args.input.conversationId as string,
|
|
483
|
+
args.input.projectId as string,
|
|
484
|
+
args.input.senderId as string,
|
|
485
|
+
(args.input.senderName as string | undefined) ?? null,
|
|
486
|
+
args.input.content as string,
|
|
487
|
+
JSON.stringify([]),
|
|
488
|
+
'SENT',
|
|
489
|
+
now,
|
|
490
|
+
now,
|
|
491
|
+
]
|
|
492
|
+
);
|
|
493
|
+
await ctx.db.execute(
|
|
494
|
+
`UPDATE template_conversation SET "lastMessageId" = $1, "updatedAt" = $2 WHERE id = $3`,
|
|
495
|
+
[id, now, args.input.conversationId as string]
|
|
496
|
+
);
|
|
497
|
+
const result = await ctx.db.query(
|
|
498
|
+
`SELECT * FROM template_message WHERE id = $1`,
|
|
499
|
+
[id]
|
|
500
|
+
);
|
|
501
|
+
if (!result.rows.length || !result.rows[0])
|
|
502
|
+
throw new Error('Failed to send message');
|
|
503
|
+
const message = mapMessage(result.rows[0]);
|
|
504
|
+
ctx.pubsub.emit('message:new', message);
|
|
505
|
+
return message;
|
|
506
|
+
},
|
|
507
|
+
setMessagesRead: async (
|
|
508
|
+
_: ResolverParent,
|
|
509
|
+
args: { conversationId: string; userId: string },
|
|
510
|
+
ctx: ResolverContext
|
|
511
|
+
) => {
|
|
512
|
+
const now = new Date().toISOString();
|
|
513
|
+
await ctx.db.execute(
|
|
514
|
+
`UPDATE template_conversation_participant
|
|
516
515
|
SET "lastReadAt" = $1
|
|
517
516
|
WHERE "conversationId" = $2 AND "userId" = $3`,
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
517
|
+
[now, args.conversationId, args.userId]
|
|
518
|
+
);
|
|
519
|
+
return true;
|
|
520
|
+
},
|
|
521
|
+
favoriteRecipe: async (
|
|
522
|
+
_: ResolverParent,
|
|
523
|
+
args: { id: string; isFavorite: boolean },
|
|
524
|
+
ctx: ResolverContext
|
|
525
|
+
) => {
|
|
526
|
+
const now = new Date().toISOString();
|
|
527
|
+
await ctx.db.execute(
|
|
528
|
+
`UPDATE template_recipe SET "isFavorite" = $1, "updatedAt" = $2 WHERE id = $3`,
|
|
529
|
+
[args.isFavorite ? 1 : 0, now, args.id]
|
|
530
|
+
);
|
|
531
|
+
const result = await ctx.db.query(
|
|
532
|
+
`SELECT * FROM template_recipe WHERE id = $1 LIMIT 1`,
|
|
533
|
+
[args.id]
|
|
534
|
+
);
|
|
535
|
+
if (!result.rows.length || !result.rows[0])
|
|
536
|
+
throw new Error('Recipe not found');
|
|
537
|
+
const locale: 'EN' | 'FR' = 'EN';
|
|
538
|
+
return mapRecipe(result.rows[0], locale);
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
Task: {
|
|
542
|
+
category: async (
|
|
543
|
+
parent: LocalRow,
|
|
544
|
+
_: unknown,
|
|
545
|
+
ctx: ResolverContext
|
|
546
|
+
) => {
|
|
547
|
+
if (!parent.categoryId) return null;
|
|
548
|
+
const result = await ctx.db.query(
|
|
549
|
+
`SELECT * FROM template_task_category WHERE id = $1 LIMIT 1`,
|
|
550
|
+
[parent.categoryId]
|
|
551
|
+
);
|
|
552
|
+
if (!result.rows.length || !result.rows[0]) return null;
|
|
553
|
+
return mapTaskCategory(result.rows[0]);
|
|
554
|
+
},
|
|
555
|
+
},
|
|
556
|
+
Conversation: {
|
|
557
|
+
participants: async (
|
|
558
|
+
parent: LocalRow,
|
|
559
|
+
_: unknown,
|
|
560
|
+
ctx: ResolverContext
|
|
561
|
+
) => {
|
|
562
|
+
const result = await ctx.db.query(
|
|
563
|
+
`SELECT * FROM template_conversation_participant WHERE "conversationId" = $1 ORDER BY "joinedAt" ASC`,
|
|
564
|
+
[parent.id]
|
|
565
|
+
);
|
|
566
|
+
return result.rows.map(mapParticipant);
|
|
567
|
+
},
|
|
568
|
+
messages: async (
|
|
569
|
+
parent: LocalRow,
|
|
570
|
+
args: { limit: number },
|
|
571
|
+
ctx: ResolverContext
|
|
572
|
+
) => {
|
|
573
|
+
const result = await ctx.db.query(
|
|
574
|
+
`SELECT * FROM template_message WHERE "conversationId" = $1 ORDER BY "createdAt" DESC LIMIT $2`,
|
|
575
|
+
[parent.id, args.limit]
|
|
576
|
+
);
|
|
577
|
+
return result.rows.map(mapMessage);
|
|
578
|
+
},
|
|
579
|
+
},
|
|
580
|
+
Recipe: {
|
|
581
|
+
category: async (
|
|
582
|
+
parent: LocalRow & { categoryId?: string | null },
|
|
583
|
+
_: unknown,
|
|
584
|
+
ctx: ResolverContext
|
|
585
|
+
) => {
|
|
586
|
+
if (!parent.categoryId) return null;
|
|
587
|
+
const result = await ctx.db.query(
|
|
588
|
+
`SELECT * FROM template_recipe_category WHERE id = $1 LIMIT 1`,
|
|
589
|
+
[parent.categoryId]
|
|
590
|
+
);
|
|
591
|
+
if (!result.rows.length || !result.rows[0]) return null;
|
|
592
|
+
return mapRecipeCategory(result.rows[0]);
|
|
593
|
+
},
|
|
594
|
+
ingredients: async (
|
|
595
|
+
parent: LocalRow & { locale: 'EN' | 'FR' },
|
|
596
|
+
_: unknown,
|
|
597
|
+
ctx: ResolverContext
|
|
598
|
+
) => {
|
|
599
|
+
const result = await ctx.db.query(
|
|
600
|
+
`SELECT * FROM template_recipe_ingredient WHERE "recipeId" = $1 ORDER BY ordering ASC`,
|
|
601
|
+
[parent.id]
|
|
602
|
+
);
|
|
603
|
+
return result.rows.map((row: LocalRow) =>
|
|
604
|
+
mapRecipeIngredient(row, parent.locale)
|
|
605
|
+
);
|
|
606
|
+
},
|
|
607
|
+
instructions: async (
|
|
608
|
+
parent: LocalRow & { locale: 'EN' | 'FR' },
|
|
609
|
+
_: unknown,
|
|
610
|
+
ctx: ResolverContext
|
|
611
|
+
) => {
|
|
612
|
+
const result = await ctx.db.query(
|
|
613
|
+
`SELECT * FROM template_recipe_instruction WHERE "recipeId" = $1 ORDER BY ordering ASC`,
|
|
614
|
+
[parent.id]
|
|
615
|
+
);
|
|
616
|
+
return result.rows.map((row: LocalRow) =>
|
|
617
|
+
mapRecipeInstruction(row, parent.locale)
|
|
618
|
+
);
|
|
619
|
+
},
|
|
620
|
+
},
|
|
621
|
+
};
|
|
622
|
+
}
|
|
624
623
|
}
|
|
625
624
|
|
|
626
625
|
function mapTaskCategory(row: LocalRow) {
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
626
|
+
return {
|
|
627
|
+
id: row.id,
|
|
628
|
+
projectId: row.projectId,
|
|
629
|
+
name: row.name,
|
|
630
|
+
color: row.color,
|
|
631
|
+
createdAt: row.createdAt,
|
|
632
|
+
updatedAt: row.updatedAt,
|
|
633
|
+
};
|
|
635
634
|
}
|
|
636
635
|
|
|
637
636
|
function mapTask(row: LocalRow) {
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
637
|
+
return {
|
|
638
|
+
id: row.id,
|
|
639
|
+
projectId: row.projectId,
|
|
640
|
+
categoryId: row.categoryId,
|
|
641
|
+
title: row.title,
|
|
642
|
+
description: row.description,
|
|
643
|
+
completed: Boolean(row.completed),
|
|
644
|
+
priority: row.priority ?? 'MEDIUM',
|
|
645
|
+
dueDate: row.dueDate,
|
|
646
|
+
tags: parseTags(row.tags),
|
|
647
|
+
createdAt: row.createdAt,
|
|
648
|
+
updatedAt: row.updatedAt,
|
|
649
|
+
};
|
|
651
650
|
}
|
|
652
651
|
|
|
653
652
|
function parseTags(value: LocalRow['tags']): string[] {
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
653
|
+
if (typeof value !== 'string') return [];
|
|
654
|
+
try {
|
|
655
|
+
const parsed = JSON.parse(value);
|
|
656
|
+
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
|
657
|
+
} catch {
|
|
658
|
+
return [];
|
|
659
|
+
}
|
|
661
660
|
}
|
|
662
661
|
|
|
663
662
|
function mapConversation(row: LocalRow) {
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
663
|
+
return {
|
|
664
|
+
id: row.id,
|
|
665
|
+
projectId: row.projectId,
|
|
666
|
+
name: row.name,
|
|
667
|
+
isGroup: Boolean(row.isGroup),
|
|
668
|
+
avatarUrl: row.avatarUrl,
|
|
669
|
+
lastMessageId: row.lastMessageId,
|
|
670
|
+
updatedAt: row.updatedAt,
|
|
671
|
+
};
|
|
673
672
|
}
|
|
674
673
|
|
|
675
674
|
function mapParticipant(row: LocalRow) {
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
675
|
+
return {
|
|
676
|
+
id: row.id,
|
|
677
|
+
conversationId: row.conversationId,
|
|
678
|
+
projectId: row.projectId,
|
|
679
|
+
userId: row.userId,
|
|
680
|
+
displayName: row.displayName,
|
|
681
|
+
role: row.role,
|
|
682
|
+
joinedAt: row.joinedAt,
|
|
683
|
+
lastReadAt: row.lastReadAt,
|
|
684
|
+
};
|
|
686
685
|
}
|
|
687
686
|
|
|
688
687
|
function mapMessage(row: LocalRow) {
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
688
|
+
return {
|
|
689
|
+
id: row.id,
|
|
690
|
+
conversationId: row.conversationId,
|
|
691
|
+
projectId: row.projectId,
|
|
692
|
+
senderId: row.senderId,
|
|
693
|
+
senderName: row.senderName,
|
|
694
|
+
content: row.content,
|
|
695
|
+
attachments: [],
|
|
696
|
+
status: row.status ?? 'SENT',
|
|
697
|
+
createdAt: row.createdAt,
|
|
698
|
+
updatedAt: row.updatedAt,
|
|
699
|
+
};
|
|
701
700
|
}
|
|
702
701
|
|
|
703
702
|
function mapRecipe(row: LocalRow, locale: 'EN' | 'FR') {
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
703
|
+
return {
|
|
704
|
+
id: row.id,
|
|
705
|
+
projectId: row.projectId,
|
|
706
|
+
slugEn: row.slugEn,
|
|
707
|
+
slugFr: row.slugFr,
|
|
708
|
+
name: locale === 'FR' ? row.nameFr : row.nameEn,
|
|
709
|
+
description: locale === 'FR' ? row.descriptionFr : row.descriptionEn,
|
|
710
|
+
heroImageUrl: row.heroImageUrl,
|
|
711
|
+
prepTimeMinutes: row.prepTimeMinutes ?? null,
|
|
712
|
+
cookTimeMinutes: row.cookTimeMinutes ?? null,
|
|
713
|
+
servings: row.servings ?? null,
|
|
714
|
+
isFavorite: Boolean(row.isFavorite),
|
|
715
|
+
locale,
|
|
716
|
+
categoryId: row.categoryId,
|
|
717
|
+
createdAt: row.createdAt,
|
|
718
|
+
updatedAt: row.updatedAt,
|
|
719
|
+
};
|
|
721
720
|
}
|
|
722
721
|
|
|
723
722
|
function mapRecipeCategory(row: LocalRow) {
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
723
|
+
return {
|
|
724
|
+
id: row.id,
|
|
725
|
+
nameEn: row.nameEn,
|
|
726
|
+
nameFr: row.nameFr,
|
|
727
|
+
icon: row.icon,
|
|
728
|
+
};
|
|
730
729
|
}
|
|
731
730
|
|
|
732
731
|
function mapRecipeIngredient(row: LocalRow, locale: 'EN' | 'FR') {
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
732
|
+
return {
|
|
733
|
+
id: row.id,
|
|
734
|
+
name: locale === 'FR' ? row.nameFr : row.nameEn,
|
|
735
|
+
quantity: row.quantity,
|
|
736
|
+
ordering: row.ordering ?? 0,
|
|
737
|
+
};
|
|
739
738
|
}
|
|
740
739
|
|
|
741
740
|
function mapRecipeInstruction(row: LocalRow, locale: 'EN' | 'FR') {
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
741
|
+
return {
|
|
742
|
+
id: row.id,
|
|
743
|
+
content: locale === 'FR' ? row.contentFr : row.contentEn,
|
|
744
|
+
ordering: row.ordering ?? 0,
|
|
745
|
+
};
|
|
747
746
|
}
|