@pylonsync/create-pylon 0.3.275 → 0.3.276

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/bin/create-pylon.js +1 -1
  2. package/package.json +1 -1
  3. package/templates/agency/app/dashboard/dashboard-client.tsx +1213 -59
  4. package/templates/agency/app/layout.tsx +1 -1
  5. package/templates/agency/app/page.tsx +72 -30
  6. package/templates/agency/app/seeder.tsx +26 -0
  7. package/templates/agency/app/work/[slug]/page.tsx +182 -0
  8. package/templates/agency/app/work/page.tsx +83 -0
  9. package/templates/agency/app.ts +168 -19
  10. package/templates/agency/components/marketing.tsx +39 -0
  11. package/templates/agency/functions/clientsForOwner.ts +27 -0
  12. package/templates/agency/functions/deleteClient.ts +27 -0
  13. package/templates/agency/functions/deleteInvoice.ts +19 -0
  14. package/templates/agency/functions/deleteProject.ts +20 -0
  15. package/templates/agency/functions/invoicesForOwner.ts +27 -0
  16. package/templates/agency/functions/seedProjects.ts +41 -0
  17. package/templates/agency/functions/seedStudioBackoffice.ts +74 -0
  18. package/templates/agency/functions/setInvoiceStatus.ts +27 -0
  19. package/templates/agency/functions/setProjectFlags.ts +35 -0
  20. package/templates/agency/functions/upsertClient.ts +73 -0
  21. package/templates/agency/functions/upsertInvoice.ts +113 -0
  22. package/templates/agency/functions/upsertProject.ts +97 -0
  23. package/templates/agency/lib/agency.ts +165 -3
  24. package/templates/agency/lib/invoice-pdf.tsx +174 -0
  25. package/templates/agency/lib/site.config.ts +180 -1
  26. package/templates/agency/package.json +2 -1
  27. package/templates/ai-chat/app/chat-client.tsx +354 -41
  28. package/templates/ai-chat/functions/deleteConversation.ts +33 -0
  29. package/templates/ai-studio/app/studio-client.tsx +172 -29
  30. package/templates/ai-studio/app.ts +7 -7
  31. package/templates/ai-studio/lib/studio.ts +5 -5
@@ -1,15 +1,14 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useEffect, useRef, useState } from "react";
4
- import { db } from "@pylonsync/react";
4
+ import { db, callFn } from "@pylonsync/react";
5
5
  import { siteConfig } from "@/lib/site.config";
6
6
 
7
7
  // The chat app — a client island, rendered only for a SIGNED-IN user (the page
8
8
  // redirects anyone else to /login). Conversations + messages are sync-backed
9
- // owner-scoped entities (`db.useQuery`), so your history is private to your
10
- // account and stays in lockstep across your tabs + devices. Sending streams
11
- // tokens from the built-in `POST /api/ai/stream` (SSE) — your PYLON_AI_API_KEY
12
- // never reaches the browser.
9
+ // owner-scoped entities (`db.useQuery`), private to your account and in lockstep
10
+ // across your tabs + devices. Sending streams tokens from the built-in
11
+ // `POST /api/ai/stream` (SSE) — your PYLON_AI_API_KEY never reaches the browser.
13
12
  //
14
13
  // All state lives in <ChatInner> (which owns `currentId`) — the thread is a
15
14
  // presentational child. That's deliberate: creating a conversation mid-send
@@ -54,7 +53,6 @@ function ChatInner() {
54
53
  const scrollRef = useRef<HTMLDivElement>(null);
55
54
  const initialized = useRef(false);
56
55
 
57
- // On first load, drop into the most recent conversation (if any).
58
56
  useEffect(() => {
59
57
  if (!initialized.current && conversations.length > 0) {
60
58
  initialized.current = true;
@@ -62,7 +60,6 @@ function ChatInner() {
62
60
  }
63
61
  }, [conversations]);
64
62
 
65
- // Keep the latest turn in view as content streams in.
66
63
  useEffect(() => {
67
64
  scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight });
68
65
  }, [messages.length, streaming]);
@@ -77,6 +74,19 @@ function ChatInner() {
77
74
  setStreaming(null);
78
75
  setNotice(null);
79
76
  }
77
+ async function renameConversation(id: string, title: string) {
78
+ const t = title.trim().slice(0, 80);
79
+ if (!t) return;
80
+ await db.update("Conversation", id, { title: t });
81
+ }
82
+ async function deleteConversation(id: string) {
83
+ if (currentId === id) setCurrentId(null);
84
+ try {
85
+ await callFn("deleteConversation", { conversationId: id });
86
+ } catch {
87
+ /* ignore */
88
+ }
89
+ }
80
90
 
81
91
  async function send(text: string) {
82
92
  const trimmed = text.trim();
@@ -85,18 +95,12 @@ function ChatInner() {
85
95
  setNotice(null);
86
96
  setInput("");
87
97
 
88
- // Snapshot the history BEFORE the async work (messages is for the current
89
- // conversation; empty for a brand-new chat).
90
98
  const history = messages.map((m) => ({ role: m.role, content: m.content }));
91
-
92
- // Make sure we have a conversation to attach to.
93
99
  let convId = currentId;
94
100
  if (!convId) {
95
101
  convId = await db.insert("Conversation", { title: trimmed.slice(0, 48) });
96
102
  setCurrentId(convId);
97
103
  }
98
-
99
- // Persist the user's turn (optimistic — paints immediately).
100
104
  await db.insert("Message", { conversationId: convId, role: "user", content: trimmed });
101
105
 
102
106
  const payload = [
@@ -144,6 +148,8 @@ function ChatInner() {
144
148
  currentId={currentId}
145
149
  onSelect={selectConversation}
146
150
  onNew={newChat}
151
+ onRename={renameConversation}
152
+ onDelete={deleteConversation}
147
153
  />
148
154
  <div className="flex flex-1 flex-col bg-white">
149
155
  <div ref={scrollRef} className="flex-1 overflow-y-auto">
@@ -153,7 +159,7 @@ function ChatInner() {
153
159
  ) : (
154
160
  <div className="space-y-5">
155
161
  {messages.map((m) => (
156
- <Bubble key={m.id} role={m.role} content={m.content} />
162
+ <Bubble key={m.id} role={m.role} content={m.content} at={m.createdAt} />
157
163
  ))}
158
164
  {streaming !== null ? <Bubble role="assistant" content={streaming || "…"} streaming /> : null}
159
165
  </div>
@@ -185,11 +191,15 @@ function Sidebar({
185
191
  currentId,
186
192
  onSelect,
187
193
  onNew,
194
+ onRename,
195
+ onDelete,
188
196
  }: {
189
197
  conversations: ConversationRow[];
190
198
  currentId: string | null;
191
199
  onSelect: (id: string) => void;
192
200
  onNew: () => void;
201
+ onRename: (id: string, title: string) => void;
202
+ onDelete: (id: string) => void;
193
203
  }) {
194
204
  return (
195
205
  <aside className="hidden w-64 shrink-0 flex-col border-r border-zinc-200 bg-paper sm:flex">
@@ -208,19 +218,14 @@ function Sidebar({
208
218
  ) : (
209
219
  <ul className="space-y-0.5">
210
220
  {conversations.map((c) => (
211
- <li key={c.id}>
212
- <button
213
- type="button"
214
- onClick={() => onSelect(c.id)}
215
- className={
216
- "w-full truncate rounded-lg px-2.5 py-2 text-left text-[13.5px] transition-colors " +
217
- (c.id === currentId ? "bg-brand-soft font-medium text-brand" : "text-zinc-600 hover:bg-zinc-100")
218
- }
219
- title={c.title}
220
- >
221
- {c.title || "New chat"}
222
- </button>
223
- </li>
221
+ <ConversationRow
222
+ key={c.id}
223
+ convo={c}
224
+ active={c.id === currentId}
225
+ onSelect={() => onSelect(c.id)}
226
+ onRename={(t) => onRename(c.id, t)}
227
+ onDelete={() => onDelete(c.id)}
228
+ />
224
229
  ))}
225
230
  </ul>
226
231
  )}
@@ -229,6 +234,104 @@ function Sidebar({
229
234
  );
230
235
  }
231
236
 
237
+ function ConversationRow({
238
+ convo,
239
+ active,
240
+ onSelect,
241
+ onRename,
242
+ onDelete,
243
+ }: {
244
+ convo: ConversationRow;
245
+ active: boolean;
246
+ onSelect: () => void;
247
+ onRename: (title: string) => void;
248
+ onDelete: () => void;
249
+ }) {
250
+ const [editing, setEditing] = useState(false);
251
+ const [draft, setDraft] = useState(convo.title);
252
+ const [confirming, setConfirming] = useState(false);
253
+
254
+ function commit() {
255
+ setEditing(false);
256
+ if (draft.trim() && draft.trim() !== convo.title) onRename(draft);
257
+ else setDraft(convo.title);
258
+ }
259
+
260
+ if (editing) {
261
+ return (
262
+ <li>
263
+ <input
264
+ autoFocus
265
+ value={draft}
266
+ onChange={(e) => setDraft(e.target.value)}
267
+ onBlur={commit}
268
+ onKeyDown={(e) => {
269
+ if (e.key === "Enter") commit();
270
+ if (e.key === "Escape") {
271
+ setDraft(convo.title);
272
+ setEditing(false);
273
+ }
274
+ }}
275
+ className="w-full rounded-lg border border-brand bg-white px-2.5 py-2 text-[13.5px] outline-none ring-2 ring-brand/20"
276
+ />
277
+ </li>
278
+ );
279
+ }
280
+
281
+ return (
282
+ <li className="group relative">
283
+ <button
284
+ type="button"
285
+ onClick={onSelect}
286
+ className={
287
+ "flex w-full items-center rounded-lg py-2 pl-2.5 pr-14 text-left text-[13.5px] transition-colors " +
288
+ (active ? "bg-brand-soft font-medium text-brand" : "text-zinc-600 hover:bg-zinc-100")
289
+ }
290
+ title={convo.title}
291
+ >
292
+ <span className="truncate">{convo.title || "New chat"}</span>
293
+ </button>
294
+ {/* hover actions */}
295
+ <div className="absolute right-1.5 top-1/2 hidden -translate-y-1/2 items-center gap-0.5 group-hover:flex">
296
+ {confirming ? (
297
+ <button
298
+ type="button"
299
+ onClick={onDelete}
300
+ className="rounded px-1.5 py-0.5 text-[11px] font-medium text-red-600 hover:bg-red-50"
301
+ >
302
+ Sure?
303
+ </button>
304
+ ) : (
305
+ <>
306
+ <button
307
+ type="button"
308
+ onClick={() => {
309
+ setDraft(convo.title);
310
+ setEditing(true);
311
+ }}
312
+ aria-label="Rename"
313
+ className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-zinc-700"
314
+ >
315
+ <PencilIcon />
316
+ </button>
317
+ <button
318
+ type="button"
319
+ onClick={() => {
320
+ setConfirming(true);
321
+ setTimeout(() => setConfirming(false), 2500);
322
+ }}
323
+ aria-label="Delete"
324
+ className="rounded p-1 text-zinc-400 hover:bg-zinc-200 hover:text-red-600"
325
+ >
326
+ <TrashIcon />
327
+ </button>
328
+ </>
329
+ )}
330
+ </div>
331
+ </li>
332
+ );
333
+ }
334
+
232
335
  function EmptyState({ onPick }: { onPick: (text: string) => void }) {
233
336
  const { chat, brand } = siteConfig;
234
337
  return (
@@ -254,11 +357,33 @@ function EmptyState({ onPick }: { onPick: (text: string) => void }) {
254
357
  );
255
358
  }
256
359
 
257
- function Bubble({ role, content, streaming }: { role: string; content: string; streaming?: boolean }) {
360
+ function Bubble({
361
+ role,
362
+ content,
363
+ streaming,
364
+ at,
365
+ }: {
366
+ role: string;
367
+ content: string;
368
+ streaming?: boolean;
369
+ at?: string;
370
+ }) {
258
371
  const { brand } = siteConfig;
259
372
  const isUser = role === "user";
373
+ const [copied, setCopied] = useState(false);
374
+
375
+ async function copy() {
376
+ try {
377
+ await navigator.clipboard.writeText(content);
378
+ setCopied(true);
379
+ setTimeout(() => setCopied(false), 1500);
380
+ } catch {
381
+ /* clipboard unavailable */
382
+ }
383
+ }
384
+
260
385
  return (
261
- <div className={"flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
386
+ <div className={"group flex gap-3 " + (isUser ? "flex-row-reverse" : "")}>
262
387
  <span
263
388
  className={
264
389
  "mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-full text-[11px] font-semibold " +
@@ -267,14 +392,34 @@ function Bubble({ role, content, streaming }: { role: string; content: string; s
267
392
  >
268
393
  {isUser ? "You" : brand.letter}
269
394
  </span>
270
- <div
271
- className={
272
- "max-w-[80%] whitespace-pre-wrap rounded-2xl px-4 py-2.5 text-[14.5px] leading-relaxed " +
273
- (isUser ? "bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-800")
274
- }
275
- >
276
- {content}
277
- {streaming ? <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-400 align-middle" /> : null}
395
+ <div className={"flex max-w-[80%] flex-col " + (isUser ? "items-end" : "items-start")}>
396
+ <div
397
+ className={
398
+ "rounded-2xl px-4 py-2.5 text-[14.5px] leading-relaxed " +
399
+ (isUser ? "whitespace-pre-wrap bg-zinc-900 text-white" : "bg-zinc-100 text-zinc-800")
400
+ }
401
+ >
402
+ {isUser || streaming ? (
403
+ <span className="whitespace-pre-wrap">{content}</span>
404
+ ) : (
405
+ <Markdown text={content} />
406
+ )}
407
+ {streaming ? <span className="ml-0.5 inline-block h-4 w-1.5 animate-pulse bg-zinc-400 align-middle" /> : null}
408
+ </div>
409
+ {!streaming ? (
410
+ <div className={"mt-1 flex items-center gap-2 px-1 " + (isUser ? "flex-row-reverse" : "")}>
411
+ {at ? <span className="text-[10.5px] text-zinc-300">{relativeTime(at)}</span> : null}
412
+ {!isUser ? (
413
+ <button
414
+ type="button"
415
+ onClick={copy}
416
+ className="text-[10.5px] text-zinc-300 opacity-0 transition-opacity hover:text-zinc-600 group-hover:opacity-100"
417
+ >
418
+ {copied ? "Copied" : "Copy"}
419
+ </button>
420
+ ) : null}
421
+ </div>
422
+ ) : null}
278
423
  </div>
279
424
  </div>
280
425
  );
@@ -348,9 +493,152 @@ function Composer({
348
493
  );
349
494
  }
350
495
 
351
- // Parse the OpenAI-style SSE stream from POST /api/ai/stream:
352
- // data: {"choices":[{"delta":{"content":"…"}}]} … data: [DONE]
353
- // Throws { code } on the 503 (AI not configured) / 429 (rate limited) shims.
496
+ /* --------------------------- markdown rendering --------------------------- */
497
+ // A small, dependency-free, XSS-safe Markdown renderer for assistant replies. It
498
+ // builds React elements directly raw HTML is never injected into the DOM, so
499
+ // model output can't smuggle scripts. Covers what LLMs emit most: fenced code
500
+ // blocks, inline `code`, **bold**, *italic*, links, bullet + numbered lists,
501
+ // headings, and paragraphs. Swap in `react-markdown` for full CommonMark/GFM.
502
+ function Markdown({ text }: { text: string }) {
503
+ return <div className="space-y-2">{renderBlocks(text)}</div>;
504
+ }
505
+
506
+ function renderBlocks(text: string): React.ReactNode[] {
507
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
508
+ const out: React.ReactNode[] = [];
509
+ let i = 0;
510
+ let key = 0;
511
+ while (i < lines.length) {
512
+ const line = lines[i];
513
+ if (line.trimStart().startsWith("```")) {
514
+ const lang = line.trim().slice(3).trim();
515
+ const code: string[] = [];
516
+ i++;
517
+ while (i < lines.length && !lines[i].trimStart().startsWith("```")) code.push(lines[i++]);
518
+ i++; // closing fence
519
+ out.push(<CodeBlock key={key++} code={code.join("\n")} lang={lang} />);
520
+ continue;
521
+ }
522
+ const h = line.match(/^(#{1,3})\s+(.*)$/);
523
+ if (h) {
524
+ const size = h[1].length === 1 ? "text-[17px]" : h[1].length === 2 ? "text-[15.5px]" : "text-[14.5px]";
525
+ out.push(
526
+ <p key={key++} className={`font-semibold text-zinc-900 ${size}`}>
527
+ {renderInline(h[2])}
528
+ </p>,
529
+ );
530
+ i++;
531
+ continue;
532
+ }
533
+ if (/^\s*[-*]\s+/.test(line)) {
534
+ const items: string[] = [];
535
+ while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*[-*]\s+/, ""));
536
+ out.push(
537
+ <ul key={key++} className="ml-4 list-disc space-y-1">
538
+ {items.map((it, j) => (
539
+ <li key={j}>{renderInline(it)}</li>
540
+ ))}
541
+ </ul>,
542
+ );
543
+ continue;
544
+ }
545
+ if (/^\s*\d+\.\s+/.test(line)) {
546
+ const items: string[] = [];
547
+ while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) items.push(lines[i++].replace(/^\s*\d+\.\s+/, ""));
548
+ out.push(
549
+ <ol key={key++} className="ml-4 list-decimal space-y-1">
550
+ {items.map((it, j) => (
551
+ <li key={j}>{renderInline(it)}</li>
552
+ ))}
553
+ </ol>,
554
+ );
555
+ continue;
556
+ }
557
+ if (line.trim() === "") {
558
+ i++;
559
+ continue;
560
+ }
561
+ const para: string[] = [];
562
+ while (
563
+ i < lines.length &&
564
+ lines[i].trim() !== "" &&
565
+ !lines[i].trimStart().startsWith("```") &&
566
+ !/^\s*[-*]\s+/.test(lines[i]) &&
567
+ !/^\s*\d+\.\s+/.test(lines[i]) &&
568
+ !/^#{1,3}\s+/.test(lines[i])
569
+ ) {
570
+ para.push(lines[i++]);
571
+ }
572
+ out.push(
573
+ <p key={key++} className="leading-relaxed">
574
+ {para.flatMap((ln, j) => (j === 0 ? renderInline(ln) : [<br key={`b${j}`} />, ...renderInline(ln)]))}
575
+ </p>,
576
+ );
577
+ }
578
+ return out;
579
+ }
580
+
581
+ function renderInline(text: string): React.ReactNode[] {
582
+ const nodes: React.ReactNode[] = [];
583
+ const re = /(`[^`]+`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
584
+ let last = 0;
585
+ let key = 0;
586
+ for (const m of text.matchAll(re)) {
587
+ const idx = m.index ?? 0;
588
+ if (idx > last) nodes.push(text.slice(last, idx));
589
+ const tok = m[0];
590
+ if (tok.startsWith("`")) {
591
+ nodes.push(
592
+ <code key={key++} className="rounded bg-zinc-200/70 px-1 py-0.5 font-mono text-[12.5px]">
593
+ {tok.slice(1, -1)}
594
+ </code>,
595
+ );
596
+ } else if (tok.startsWith("**")) {
597
+ nodes.push(<strong key={key++}>{tok.slice(2, -2)}</strong>);
598
+ } else if (tok.startsWith("*")) {
599
+ nodes.push(<em key={key++}>{tok.slice(1, -1)}</em>);
600
+ } else {
601
+ const lm = tok.match(/\[([^\]]+)\]\(([^)]+)\)/);
602
+ if (lm) {
603
+ nodes.push(
604
+ <a key={key++} href={lm[2]} target="_blank" rel="noopener noreferrer" className="text-brand underline">
605
+ {lm[1]}
606
+ </a>,
607
+ );
608
+ }
609
+ }
610
+ last = idx + tok.length;
611
+ }
612
+ if (last < text.length) nodes.push(text.slice(last));
613
+ return nodes;
614
+ }
615
+
616
+ function CodeBlock({ code, lang }: { code: string; lang?: string }) {
617
+ const [copied, setCopied] = useState(false);
618
+ async function copy() {
619
+ try {
620
+ await navigator.clipboard.writeText(code);
621
+ setCopied(true);
622
+ setTimeout(() => setCopied(false), 1500);
623
+ } catch {
624
+ /* ignore */
625
+ }
626
+ }
627
+ return (
628
+ <div className="overflow-hidden rounded-lg border border-zinc-200 bg-zinc-950">
629
+ <div className="flex items-center justify-between border-b border-white/10 px-3 py-1.5">
630
+ <span className="font-mono text-[10.5px] uppercase tracking-wide text-zinc-400">{lang || "code"}</span>
631
+ <button type="button" onClick={copy} className="text-[10.5px] text-zinc-400 hover:text-white">
632
+ {copied ? "Copied" : "Copy"}
633
+ </button>
634
+ </div>
635
+ <pre className="overflow-x-auto p-3 text-[12.5px] leading-relaxed text-zinc-100">
636
+ <code className="font-mono">{code}</code>
637
+ </pre>
638
+ </div>
639
+ );
640
+ }
641
+
354
642
  async function streamCompletion(
355
643
  messages: { role: string; content: string }[],
356
644
  model: string,
@@ -397,6 +685,18 @@ async function streamCompletion(
397
685
  }
398
686
  }
399
687
 
688
+ function relativeTime(iso: string): string {
689
+ const t = new Date(iso).getTime();
690
+ if (Number.isNaN(t)) return "";
691
+ const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
692
+ if (s < 45) return "just now";
693
+ const m = Math.floor(s / 60);
694
+ if (m < 60) return `${m}m ago`;
695
+ const h = Math.floor(m / 60);
696
+ if (h < 24) return `${h}h ago`;
697
+ return `${Math.floor(h / 24)}d ago`;
698
+ }
699
+
400
700
  function PlusIcon() {
401
701
  return (
402
702
  <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" aria-hidden>
@@ -411,4 +711,17 @@ function SendIcon() {
411
711
  </svg>
412
712
  );
413
713
  }
414
-
714
+ function PencilIcon() {
715
+ return (
716
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
717
+ <path d="M12 20h9M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
718
+ </svg>
719
+ );
720
+ }
721
+ function TrashIcon() {
722
+ return (
723
+ <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
724
+ <path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
725
+ </svg>
726
+ );
727
+ }
@@ -0,0 +1,33 @@
1
+ import { mutation, v } from "@pylonsync/functions";
2
+
3
+ // deleteConversation — remove a conversation AND all its messages in one
4
+ // transaction. The client could delete the Conversation row itself (it's
5
+ // owner-scoped), but that would orphan the messages; this cascades. Gated to the
6
+ // owner: it verifies the conversation belongs to the caller before deleting.
7
+ export default mutation<{ conversationId: string }, { ok: boolean; deleted: number }>({
8
+ auth: "user",
9
+ args: { conversationId: v.id("Conversation") },
10
+ async handler(ctx, args) {
11
+ const convo = (await ctx.db.get("Conversation", args.conversationId)) as
12
+ | { userId: string }
13
+ | null;
14
+ if (!convo) return { ok: true, deleted: 0 };
15
+ if (convo.userId !== ctx.auth.userId) {
16
+ throw ctx.error("POLICY_DENIED", "You can only delete your own conversations.");
17
+ }
18
+
19
+ const messages = (await ctx.db.unsafe.list("Message")) as unknown as {
20
+ id: string;
21
+ conversationId: string;
22
+ }[];
23
+ let deleted = 0;
24
+ for (const m of messages) {
25
+ if (m.conversationId === args.conversationId) {
26
+ await ctx.db.unsafe.delete("Message", m.id);
27
+ deleted++;
28
+ }
29
+ }
30
+ await ctx.db.unsafe.delete("Conversation", args.conversationId);
31
+ return { ok: true, deleted };
32
+ },
33
+ });