@contractspec/lib.runtime-sandbox 0.11.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/_virtual/rolldown_runtime.js +18 -0
- package/dist/adapters/pglite/adapter.js +97 -0
- package/dist/adapters/pglite/adapter.js.map +1 -0
- package/dist/adapters/pglite/index.js +3 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/ports/database.port.d.ts +70 -0
- package/dist/ports/database.port.d.ts.map +1 -0
- package/dist/types/database.types.d.ts +47 -0
- package/dist/types/database.types.d.ts.map +1 -0
- package/dist/web/database/migrations.d.ts +12 -0
- package/dist/web/database/migrations.d.ts.map +1 -0
- package/dist/web/database/migrations.js +746 -0
- package/dist/web/database/migrations.js.map +1 -0
- package/dist/web/database/schema.d.ts +7349 -0
- package/dist/web/database/schema.d.ts.map +1 -0
- package/dist/web/database/schema.js +528 -0
- package/dist/web/database/schema.js.map +1 -0
- package/dist/web/events/local-pubsub.d.ts +10 -0
- package/dist/web/events/local-pubsub.d.ts.map +1 -0
- package/dist/web/events/local-pubsub.js +24 -0
- package/dist/web/events/local-pubsub.js.map +1 -0
- package/dist/web/graphql/local-client.d.ts +20 -0
- package/dist/web/graphql/local-client.d.ts.map +1 -0
- package/dist/web/graphql/local-client.js +536 -0
- package/dist/web/graphql/local-client.js.map +1 -0
- package/dist/web/index.d.ts +15 -0
- package/dist/web/index.d.ts.map +1 -0
- package/dist/web/index.js +68 -0
- package/dist/web/index.js.map +1 -0
- package/dist/web/runtime/seeders/index.js +358 -0
- package/dist/web/runtime/seeders/index.js.map +1 -0
- package/dist/web/runtime/services.d.ts +60 -0
- package/dist/web/runtime/services.d.ts.map +1 -0
- package/dist/web/runtime/services.js +80 -0
- package/dist/web/runtime/services.js.map +1 -0
- package/dist/web/storage/indexeddb.d.ts +22 -0
- package/dist/web/storage/indexeddb.d.ts.map +1 -0
- package/dist/web/storage/indexeddb.js +85 -0
- package/dist/web/storage/indexeddb.js.map +1 -0
- package/dist/web/utils/id.d.ts +5 -0
- package/dist/web/utils/id.d.ts.map +1 -0
- package/dist/web/utils/id.js +9 -0
- package/dist/web/utils/id.js.map +1 -0
- package/package.json +70 -0
- package/src/adapters/pglite/adapter.ts +152 -0
- package/src/adapters/pglite/index.ts +1 -0
- package/src/index.ts +41 -0
- package/src/ports/database.port.ts +82 -0
- package/src/ports/index.ts +4 -0
- package/src/types/database.types.ts +55 -0
- package/src/types/index.ts +1 -0
- package/src/web/database/migrations.ts +760 -0
- package/src/web/database/schema.ts +596 -0
- package/src/web/events/local-pubsub.ts +28 -0
- package/src/web/graphql/local-client.ts +747 -0
- package/src/web/index.ts +21 -0
- package/src/web/runtime/seeders/index.ts +449 -0
- package/src/web/runtime/services.ts +132 -0
- package/src/web/storage/indexeddb.ts +116 -0
- package/src/web/utils/id.ts +7 -0
|
@@ -0,0 +1,747 @@
|
|
|
1
|
+
import { ApolloClient, InMemoryCache } from '@apollo/client';
|
|
2
|
+
import { SchemaLink } from '@apollo/client/link/schema';
|
|
3
|
+
import { makeExecutableSchema } from '@graphql-tools/schema';
|
|
4
|
+
import { GraphQLScalarType, Kind } from 'graphql';
|
|
5
|
+
|
|
6
|
+
import type { DatabasePort, DbRow } from '@contractspec/lib.runtime-sandbox';
|
|
7
|
+
import { LocalEventBus } from '../events/local-pubsub';
|
|
8
|
+
import { LocalStorageService } from '../storage/indexeddb';
|
|
9
|
+
import { generateId } from '../utils/id';
|
|
10
|
+
|
|
11
|
+
const typeDefs = /* GraphQL */ `
|
|
12
|
+
scalar DateTime
|
|
13
|
+
|
|
14
|
+
enum TaskPriority {
|
|
15
|
+
LOW
|
|
16
|
+
MEDIUM
|
|
17
|
+
HIGH
|
|
18
|
+
URGENT
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
enum MessageStatus {
|
|
22
|
+
SENT
|
|
23
|
+
DELIVERED
|
|
24
|
+
READ
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
enum RecipeLocale {
|
|
28
|
+
EN
|
|
29
|
+
FR
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
type TaskCategory {
|
|
33
|
+
id: ID!
|
|
34
|
+
projectId: ID!
|
|
35
|
+
name: String!
|
|
36
|
+
color: String
|
|
37
|
+
createdAt: DateTime!
|
|
38
|
+
updatedAt: DateTime!
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type Task {
|
|
42
|
+
id: ID!
|
|
43
|
+
projectId: ID!
|
|
44
|
+
categoryId: ID
|
|
45
|
+
title: String!
|
|
46
|
+
description: String
|
|
47
|
+
completed: Boolean!
|
|
48
|
+
priority: TaskPriority!
|
|
49
|
+
dueDate: DateTime
|
|
50
|
+
tags: [String!]!
|
|
51
|
+
createdAt: DateTime!
|
|
52
|
+
updatedAt: DateTime!
|
|
53
|
+
category: TaskCategory
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
input CreateTaskInput {
|
|
57
|
+
projectId: ID!
|
|
58
|
+
categoryId: ID
|
|
59
|
+
title: String!
|
|
60
|
+
description: String
|
|
61
|
+
priority: TaskPriority = MEDIUM
|
|
62
|
+
dueDate: DateTime
|
|
63
|
+
tags: [String!]
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
input UpdateTaskInput {
|
|
67
|
+
categoryId: ID
|
|
68
|
+
title: String
|
|
69
|
+
description: String
|
|
70
|
+
priority: TaskPriority
|
|
71
|
+
dueDate: DateTime
|
|
72
|
+
tags: [String!]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
type ConversationParticipant {
|
|
76
|
+
id: ID!
|
|
77
|
+
conversationId: ID!
|
|
78
|
+
projectId: ID!
|
|
79
|
+
userId: String!
|
|
80
|
+
displayName: String
|
|
81
|
+
role: String
|
|
82
|
+
joinedAt: DateTime!
|
|
83
|
+
lastReadAt: DateTime
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
input ConversationParticipantInput {
|
|
87
|
+
userId: String!
|
|
88
|
+
displayName: String
|
|
89
|
+
role: String
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
type Message {
|
|
93
|
+
id: ID!
|
|
94
|
+
conversationId: ID!
|
|
95
|
+
projectId: ID!
|
|
96
|
+
senderId: String!
|
|
97
|
+
senderName: String
|
|
98
|
+
content: String!
|
|
99
|
+
attachments: [String!]!
|
|
100
|
+
status: MessageStatus!
|
|
101
|
+
createdAt: DateTime!
|
|
102
|
+
updatedAt: DateTime!
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
input SendMessageInput {
|
|
106
|
+
conversationId: ID!
|
|
107
|
+
projectId: ID!
|
|
108
|
+
senderId: String!
|
|
109
|
+
senderName: String
|
|
110
|
+
content: String!
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
input CreateConversationInput {
|
|
114
|
+
projectId: ID!
|
|
115
|
+
name: String
|
|
116
|
+
isGroup: Boolean = false
|
|
117
|
+
avatarUrl: String
|
|
118
|
+
participants: [ConversationParticipantInput!]!
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
type Conversation {
|
|
122
|
+
id: ID!
|
|
123
|
+
projectId: ID!
|
|
124
|
+
name: String
|
|
125
|
+
isGroup: Boolean!
|
|
126
|
+
avatarUrl: String
|
|
127
|
+
lastMessageId: ID
|
|
128
|
+
updatedAt: DateTime!
|
|
129
|
+
participants: [ConversationParticipant!]!
|
|
130
|
+
messages(limit: Int = 50): [Message!]!
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
type RecipeCategory {
|
|
134
|
+
id: ID!
|
|
135
|
+
nameEn: String!
|
|
136
|
+
nameFr: String!
|
|
137
|
+
icon: String
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
type RecipeIngredient {
|
|
141
|
+
id: ID!
|
|
142
|
+
name: String!
|
|
143
|
+
quantity: String!
|
|
144
|
+
ordering: Int!
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
type RecipeInstruction {
|
|
148
|
+
id: ID!
|
|
149
|
+
content: String!
|
|
150
|
+
ordering: Int!
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
type Recipe {
|
|
154
|
+
id: ID!
|
|
155
|
+
projectId: ID!
|
|
156
|
+
slugEn: String!
|
|
157
|
+
slugFr: String!
|
|
158
|
+
name: String!
|
|
159
|
+
description: String
|
|
160
|
+
heroImageUrl: String
|
|
161
|
+
prepTimeMinutes: Int
|
|
162
|
+
cookTimeMinutes: Int
|
|
163
|
+
servings: Int
|
|
164
|
+
isFavorite: Boolean!
|
|
165
|
+
locale: RecipeLocale!
|
|
166
|
+
category: RecipeCategory
|
|
167
|
+
ingredients: [RecipeIngredient!]!
|
|
168
|
+
instructions: [RecipeInstruction!]!
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
type Query {
|
|
172
|
+
taskCategories(projectId: ID!): [TaskCategory!]!
|
|
173
|
+
tasks(projectId: ID!): [Task!]!
|
|
174
|
+
conversations(projectId: ID!): [Conversation!]!
|
|
175
|
+
messages(conversationId: ID!, limit: Int = 50): [Message!]!
|
|
176
|
+
recipes(projectId: ID!, locale: RecipeLocale = EN): [Recipe!]!
|
|
177
|
+
recipe(id: ID!, locale: RecipeLocale = EN): Recipe
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
type Mutation {
|
|
181
|
+
createTask(input: CreateTaskInput!): Task!
|
|
182
|
+
updateTask(id: ID!, input: UpdateTaskInput!): Task!
|
|
183
|
+
toggleTask(id: ID!, completed: Boolean!): Task!
|
|
184
|
+
deleteTask(id: ID!): Boolean!
|
|
185
|
+
createConversation(input: CreateConversationInput!): Conversation!
|
|
186
|
+
sendMessage(input: SendMessageInput!): Message!
|
|
187
|
+
setMessagesRead(conversationId: ID!, userId: String!): Boolean!
|
|
188
|
+
favoriteRecipe(id: ID!, isFavorite: Boolean!): Recipe!
|
|
189
|
+
}
|
|
190
|
+
`;
|
|
191
|
+
|
|
192
|
+
interface ResolverContext {
|
|
193
|
+
db: DatabasePort;
|
|
194
|
+
storage: LocalStorageService;
|
|
195
|
+
pubsub: LocalEventBus;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
type ResolverParent = Record<string, unknown>;
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Local row type for query results
|
|
202
|
+
*/
|
|
203
|
+
type LocalRow = DbRow;
|
|
204
|
+
|
|
205
|
+
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
|
+
},
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
export interface LocalGraphQLClientOptions {
|
|
224
|
+
db: DatabasePort;
|
|
225
|
+
storage: LocalStorageService;
|
|
226
|
+
pubsub?: LocalEventBus;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
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")
|
|
339
|
+
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
|
|
370
|
+
SET "categoryId" = COALESCE($1, "categoryId"),
|
|
371
|
+
title = COALESCE($2, title),
|
|
372
|
+
description = COALESCE($3, description),
|
|
373
|
+
priority = COALESCE($4, priority),
|
|
374
|
+
"dueDate" = COALESCE($5, "dueDate"),
|
|
375
|
+
tags = COALESCE($6, tags),
|
|
376
|
+
"updatedAt" = $7
|
|
377
|
+
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")
|
|
434
|
+
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")
|
|
450
|
+
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")
|
|
480
|
+
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
|
|
516
|
+
SET "lastReadAt" = $1
|
|
517
|
+
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
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
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
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
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
|
+
};
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
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
|
+
}
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
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
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
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
|
+
};
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
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
|
+
};
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
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
|
+
};
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
function mapRecipeCategory(row: LocalRow) {
|
|
724
|
+
return {
|
|
725
|
+
id: row.id,
|
|
726
|
+
nameEn: row.nameEn,
|
|
727
|
+
nameFr: row.nameFr,
|
|
728
|
+
icon: row.icon,
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
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
|
+
};
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
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
|
+
};
|
|
747
|
+
}
|