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