@mevdragon/vidfarm-devcli 0.2.8 → 0.2.10

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 (98) hide show
  1. package/.env.example +9 -0
  2. package/GETTING_STARTED.developers.md +1 -26
  3. package/README.md +3 -0
  4. package/SKILL.developer.md +419 -8
  5. package/auto-create-templates/AUTO_CREATE_TEMPLATES.md +475 -0
  6. package/auto-create-templates/extractor-system-prompt.md +113 -0
  7. package/auto-create-templates/production-graph.schema.json +276 -0
  8. package/auto-create-templates/runbook.md +140 -0
  9. package/auto-create-templates/template-plan.schema.json +230 -0
  10. package/auto-create-templates/template-planner-prompt.md +75 -0
  11. package/dist/src/account-pages-legacy.js +9064 -0
  12. package/dist/src/account-pages.js +41 -620
  13. package/dist/src/app.js +6627 -244
  14. package/dist/src/cli.js +386 -26
  15. package/dist/src/config.js +12 -2
  16. package/dist/src/context.js +35 -35
  17. package/dist/src/db.js +1016 -53
  18. package/dist/src/dev-app-legacy.js +693 -0
  19. package/dist/src/dev-app.js +4 -904
  20. package/dist/src/domain.js +1 -1
  21. package/dist/src/editor-chat-history.js +72 -0
  22. package/dist/src/editor-chat.js +202 -0
  23. package/dist/src/frontend/flockposter-cache-store.js +116 -0
  24. package/dist/src/frontend/homepage-client.js +114 -0
  25. package/dist/src/frontend/homepage-shared.js +3 -0
  26. package/dist/src/frontend/homepage-store.js +27 -0
  27. package/dist/src/frontend/homepage-view.js +147 -0
  28. package/dist/src/frontend/page-runtime-client.js +100 -0
  29. package/dist/src/frontend/page-runtime-store.js +8 -0
  30. package/dist/src/frontend/template-editor-chat.js +2261 -0
  31. package/dist/src/homepage.js +695 -371
  32. package/dist/src/lib/template-style-options.js +95 -15
  33. package/dist/src/page-runtime.js +1 -0
  34. package/dist/src/page-shell.js +941 -0
  35. package/dist/src/primitive-context.js +163 -0
  36. package/dist/src/primitive-registry.js +340 -0
  37. package/dist/src/primitive-sdk.js +3 -0
  38. package/dist/src/primitives/remotion/html-image.js +28 -0
  39. package/dist/src/react-page-shell.js +34 -0
  40. package/dist/src/ready-post-schedule-component.js +1514 -0
  41. package/dist/src/registry.js +36 -17
  42. package/dist/src/runtime.js +6 -1
  43. package/dist/src/services/api-call-history.js +245 -0
  44. package/dist/src/services/auth.js +25 -3
  45. package/dist/src/services/billing.js +248 -9
  46. package/dist/src/services/chat-threads.js +88 -0
  47. package/dist/src/services/job-logs.js +14 -7
  48. package/dist/src/services/jobs.js +13 -2
  49. package/dist/src/services/providers.js +552 -133
  50. package/dist/src/services/rate-limits.js +236 -0
  51. package/dist/src/services/remotion.js +143 -16
  52. package/dist/src/services/storage.js +10 -8
  53. package/dist/src/services/template-certification.js +11 -2
  54. package/dist/src/services/template-loader.js +3 -1
  55. package/dist/src/services/template-sources.js +68 -3
  56. package/dist/src/template-editor-pages.js +2309 -0
  57. package/dist/src/template-editor-shell.js +1507 -0
  58. package/dist/src/worker.js +120 -22
  59. package/package.json +27 -4
  60. package/public/assets/homepage-app.js +54 -0
  61. package/public/assets/homepage-client-app.js +54 -0
  62. package/public/assets/page-runtime-client-app.js +68 -0
  63. package/dist/templates/vidfarm_template_0000/src/lib/images.js +0 -202
  64. package/dist/templates/vidfarm_template_0000/src/remotion/Root.js +0 -34
  65. package/dist/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
  66. package/dist/templates/vidfarm_template_0000/src/sdk.js +0 -3
  67. package/dist/templates/vidfarm_template_0000/src/style-options.js +0 -51
  68. package/dist/templates/vidfarm_template_0000/src/template-dna.js +0 -9
  69. package/dist/templates/vidfarm_template_0000/src/template.js +0 -1447
  70. package/templates/vidfarm_template_0000/README.md +0 -100
  71. package/templates/vidfarm_template_0000/SKILL.md +0 -225
  72. package/templates/vidfarm_template_0000/assets/Abel-Regular.ttf +0 -0
  73. package/templates/vidfarm_template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  74. package/templates/vidfarm_template_0000/assets/Montserrat[wght].ttf +0 -0
  75. package/templates/vidfarm_template_0000/assets/SourceCodePro[wght].ttf +0 -0
  76. package/templates/vidfarm_template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  77. package/templates/vidfarm_template_0000/assets/Yesteryear-Regular.ttf +0 -0
  78. package/templates/vidfarm_template_0000/composition.json +0 -11
  79. package/templates/vidfarm_template_0000/package.json +0 -27
  80. package/templates/vidfarm_template_0000/research/preview/.gitkeep +0 -1
  81. package/templates/vidfarm_template_0000/research/source_notes.md +0 -7
  82. package/templates/vidfarm_template_0000/src/lib/images.js +0 -202
  83. package/templates/vidfarm_template_0000/src/lib/images.ts +0 -241
  84. package/templates/vidfarm_template_0000/src/remotion/Root.js +0 -33
  85. package/templates/vidfarm_template_0000/src/remotion/Root.tsx +0 -75
  86. package/templates/vidfarm_template_0000/src/remotion/index.js +0 -3
  87. package/templates/vidfarm_template_0000/src/remotion/index.tsx +0 -4
  88. package/templates/vidfarm_template_0000/src/sdk.js +0 -3
  89. package/templates/vidfarm_template_0000/src/sdk.ts +0 -140
  90. package/templates/vidfarm_template_0000/src/style-options.js +0 -51
  91. package/templates/vidfarm_template_0000/src/style-options.ts +0 -60
  92. package/templates/vidfarm_template_0000/src/template-dna.js +0 -9
  93. package/templates/vidfarm_template_0000/src/template-dna.ts +0 -15
  94. package/templates/vidfarm_template_0000/src/template.js +0 -1447
  95. package/templates/vidfarm_template_0000/src/template.ts +0 -2049
  96. package/templates/vidfarm_template_0000/template.config.json +0 -21
  97. package/templates/vidfarm_template_0000/tmp/solobacterium-moorei-slideshow.request.json +0 -31
  98. package/templates/vidfarm_template_0000/tsconfig.json +0 -19
package/dist/src/db.js CHANGED
@@ -14,6 +14,7 @@ create table if not exists customers (
14
14
  default_webhook_url text,
15
15
  is_developer integer not null default 0,
16
16
  is_paid_plan integer not null default 0,
17
+ plan_tier text not null default 'starter',
17
18
  about text,
18
19
  groupchat_url text,
19
20
  flockposter_api_key text,
@@ -50,7 +51,6 @@ create table if not exists customer_provider_keys (
50
51
  label text,
51
52
  encrypted_secret text not null,
52
53
  status text not null,
53
- weight integer not null default 1,
54
54
  last_used_at text,
55
55
  cooldown_until text,
56
56
  disabled_reason text,
@@ -67,6 +67,19 @@ create table if not exists provider_key_leases (
67
67
  expires_at text not null
68
68
  );
69
69
 
70
+ create table if not exists provider_key_waiters (
71
+ id text primary key,
72
+ customer_id text not null references customers(id) on delete cascade,
73
+ provider text not null,
74
+ worker_id text not null,
75
+ owner_id text not null,
76
+ priority integer not null default 100,
77
+ created_at text not null,
78
+ expires_at text not null
79
+ );
80
+
81
+ create index if not exists idx_provider_key_waiters_scope on provider_key_waiters(customer_id, provider, priority, created_at);
82
+
70
83
  create table if not exists provider_key_usage_events (
71
84
  id text primary key,
72
85
  key_id text not null references customer_provider_keys(id) on delete cascade,
@@ -129,18 +142,20 @@ create table if not exists artifacts (
129
142
  created_at text not null
130
143
  );
131
144
 
132
- create table if not exists billing_events (
145
+ drop table if exists billing_events;
146
+
147
+ create table if not exists customer_credit_grants (
133
148
  id text primary key,
134
149
  customer_id text not null references customers(id) on delete cascade,
135
- job_id text not null references jobs(id) on delete cascade,
136
- template_id text not null,
137
- type text not null,
138
- cost_usd real not null,
139
- charge_usd real not null,
150
+ grant_type text not null,
151
+ amount_usd real not null,
140
152
  metadata_json text not null,
141
- created_at text not null
153
+ created_at text not null,
154
+ unique(customer_id, grant_type)
142
155
  );
143
156
 
157
+ create index if not exists idx_customer_credit_grants_customer_created on customer_credit_grants(customer_id, created_at desc);
158
+
144
159
  create table if not exists webhook_deliveries (
145
160
  id text primary key,
146
161
  job_id text not null references jobs(id) on delete cascade,
@@ -169,6 +184,110 @@ create table if not exists user_attachments (
169
184
 
170
185
  create index if not exists idx_user_attachments_customer_created on user_attachments(customer_id, created_at desc);
171
186
 
187
+ create table if not exists email_channels (
188
+ id text primary key,
189
+ customer_id text not null references customers(id) on delete cascade,
190
+ title text not null,
191
+ email text not null,
192
+ note text,
193
+ avatar_url text,
194
+ avatar_storage_key text,
195
+ status text not null,
196
+ verification_token_hash text,
197
+ verification_sent_at text,
198
+ verified_at text,
199
+ created_at text not null,
200
+ updated_at text not null,
201
+ unique(customer_id, email)
202
+ );
203
+
204
+ create index if not exists idx_email_channels_customer_created on email_channels(customer_id, created_at desc);
205
+ create index if not exists idx_email_channels_token on email_channels(verification_token_hash);
206
+
207
+ create table if not exists ready_posts (
208
+ id text primary key,
209
+ customer_id text not null references customers(id) on delete cascade,
210
+ status text not null,
211
+ tracer text,
212
+ title text,
213
+ content text not null,
214
+ pinned_comment text,
215
+ scheduled_at text,
216
+ timezone text,
217
+ post_type text not null,
218
+ integration_ids_json text not null,
219
+ platform_targets_json text not null,
220
+ media_assets_json text not null,
221
+ renditions_json text not null,
222
+ flockposter_payload_json text not null,
223
+ field_values_json text not null,
224
+ share_password_hash text,
225
+ zip_storage_key text,
226
+ zip_public_url text,
227
+ archived_at text,
228
+ deleted_at text,
229
+ created_at text not null,
230
+ updated_at text not null
231
+ );
232
+
233
+ create index if not exists idx_ready_posts_customer_created on ready_posts(customer_id, created_at desc);
234
+
235
+ create table if not exists post_schedules (
236
+ id text primary key,
237
+ customer_id text not null references customers(id) on delete cascade,
238
+ ready_post_id text references ready_posts(id) on delete cascade,
239
+ managed_by text not null,
240
+ provider text not null,
241
+ destination_type text not null,
242
+ destination_id text not null,
243
+ scheduled_at text not null,
244
+ timezone text,
245
+ status text not null,
246
+ provider_status text,
247
+ provider_schedule_id text,
248
+ title text,
249
+ summary text,
250
+ url text,
251
+ additional_notes text,
252
+ provider_payload_json text not null,
253
+ provider_response_json text not null,
254
+ last_synced_at text,
255
+ last_error text,
256
+ cancelled_at text,
257
+ sent_at text,
258
+ created_at text not null,
259
+ updated_at text not null
260
+ );
261
+
262
+ create index if not exists idx_post_schedules_customer_scheduled on post_schedules(customer_id, scheduled_at asc);
263
+ create index if not exists idx_post_schedules_ready_post on post_schedules(ready_post_id, status);
264
+ create index if not exists idx_post_schedules_provider_id on post_schedules(customer_id, provider, provider_schedule_id);
265
+
266
+ create table if not exists chat_threads (
267
+ id text primary key,
268
+ customer_id text not null references customers(id) on delete cascade,
269
+ template_id text not null,
270
+ title text not null,
271
+ storage_prefix text not null,
272
+ message_count integer not null default 0,
273
+ last_message_at text,
274
+ hidden_from_view integer not null default 0,
275
+ archived_at text,
276
+ created_at text not null,
277
+ updated_at text not null
278
+ );
279
+
280
+ create index if not exists idx_chat_threads_customer_template_updated on chat_threads(customer_id, template_id, updated_at desc);
281
+
282
+ create table if not exists chat_thread_tracers (
283
+ thread_id text not null references chat_threads(id) on delete cascade,
284
+ tracer text not null,
285
+ created_at text not null,
286
+ primary key (thread_id, tracer)
287
+ );
288
+
289
+ create index if not exists idx_chat_thread_tracers_tracer on chat_thread_tracers(tracer, thread_id);
290
+
172
291
  create table if not exists template_sources (
173
292
  id text primary key,
174
293
  template_id text not null unique,
@@ -212,6 +331,16 @@ if (!customerColumns.some((column) => column.name === "is_developer")) {
212
331
  if (!customerColumns.some((column) => column.name === "is_paid_plan")) {
213
332
  db.exec(`alter table customers add column is_paid_plan integer not null default 0;`);
214
333
  }
334
+ if (!customerColumns.some((column) => column.name === "plan_tier")) {
335
+ db.exec(`alter table customers add column plan_tier text not null default 'starter';`);
336
+ }
337
+ db.exec(`
338
+ update customers
339
+ set plan_tier = 'starter'
340
+ where plan_tier in ('standard', 'invisible')
341
+ or plan_tier is null
342
+ or trim(plan_tier) = ''
343
+ `);
215
344
  if (!customerColumns.some((column) => column.name === "password_hash")) {
216
345
  db.exec(`alter table customers add column password_hash text;`);
217
346
  }
@@ -228,6 +357,10 @@ const apiKeyColumns = db.prepare(`pragma table_info(api_keys)`).all();
228
357
  if (!apiKeyColumns.some((column) => column.name === "raw_value")) {
229
358
  db.exec(`alter table api_keys add column raw_value text;`);
230
359
  }
360
+ const providerKeyColumns = db.prepare(`pragma table_info(customer_provider_keys)`).all();
361
+ if (providerKeyColumns.some((column) => column.name === "weight")) {
362
+ db.exec(`alter table customer_provider_keys drop column weight;`);
363
+ }
231
364
  const templateSourceColumns = db.prepare(`pragma table_info(template_sources)`).all();
232
365
  if (!templateSourceColumns.some((column) => column.name === "slug_id")) {
233
366
  db.exec(`alter table template_sources add column slug_id text;`);
@@ -255,6 +388,24 @@ if (!templateReleaseColumns.some((column) => column.name === "artifact_manifest_
255
388
  if (!templateReleaseColumns.some((column) => column.name === "artifact_key")) {
256
389
  db.exec(`alter table template_releases add column artifact_key text;`);
257
390
  }
391
+ const readyPostColumns = db.prepare(`pragma table_info(ready_posts)`).all();
392
+ if (!readyPostColumns.some((column) => column.name === "tracer")) {
393
+ db.exec(`alter table ready_posts add column tracer text;`);
394
+ }
395
+ if (!readyPostColumns.some((column) => column.name === "pinned_comment")) {
396
+ db.exec(`alter table ready_posts add column pinned_comment text;`);
397
+ }
398
+ const postScheduleColumns = db.prepare(`pragma table_info(post_schedules)`).all();
399
+ if (!postScheduleColumns.some((column) => column.name === "additional_notes")) {
400
+ db.exec(`alter table post_schedules add column additional_notes text;`);
401
+ }
402
+ const emailChannelColumns = db.prepare(`pragma table_info(email_channels)`).all();
403
+ if (!emailChannelColumns.some((column) => column.name === "avatar_url")) {
404
+ db.exec(`alter table email_channels add column avatar_url text;`);
405
+ }
406
+ if (!emailChannelColumns.some((column) => column.name === "avatar_storage_key")) {
407
+ db.exec(`alter table email_channels add column avatar_storage_key text;`);
408
+ }
258
409
  db.exec(`drop index if exists idx_job_events_job_time;`);
259
410
  db.exec(`drop table if exists job_events;`);
260
411
  function mapJob(row) {
@@ -329,6 +480,101 @@ function mapUserAttachment(row) {
329
480
  createdAt: String(row.created_at)
330
481
  };
331
482
  }
483
+ function mapEmailChannel(row) {
484
+ const status = String(row.status) === "verified" ? "verified" : "unverified";
485
+ return {
486
+ id: String(row.id),
487
+ customerId: String(row.customer_id),
488
+ title: String(row.title),
489
+ email: String(row.email),
490
+ note: row.note ? String(row.note) : null,
491
+ avatarUrl: row.avatar_url ? String(row.avatar_url) : null,
492
+ avatarStorageKey: row.avatar_storage_key ? String(row.avatar_storage_key) : null,
493
+ status,
494
+ verificationTokenHash: row.verification_token_hash ? String(row.verification_token_hash) : null,
495
+ verificationSentAt: row.verification_sent_at ? String(row.verification_sent_at) : null,
496
+ verifiedAt: row.verified_at ? String(row.verified_at) : null,
497
+ createdAt: String(row.created_at),
498
+ updatedAt: String(row.updated_at)
499
+ };
500
+ }
501
+ function mapArtifact(row) {
502
+ return {
503
+ id: String(row.id),
504
+ jobId: String(row.job_id),
505
+ customerId: String(row.customer_id),
506
+ templateId: String(row.template_id),
507
+ kind: String(row.kind),
508
+ storageKey: String(row.storage_key),
509
+ publicUrl: row.public_url ? String(row.public_url) : null,
510
+ metadata: parseJson(row.metadata_json, {}),
511
+ createdAt: String(row.created_at)
512
+ };
513
+ }
514
+ function mapReadyPost(row) {
515
+ return {
516
+ id: String(row.id),
517
+ customerId: String(row.customer_id),
518
+ status: row.status,
519
+ tracer: row.tracer ? String(row.tracer) : null,
520
+ title: row.title ? String(row.title) : null,
521
+ caption: String(row.content),
522
+ pinnedComment: row.pinned_comment ? String(row.pinned_comment) : null,
523
+ mediaAssets: parseJson(row.media_assets_json, []),
524
+ sharePasswordHash: row.share_password_hash ? String(row.share_password_hash) : null,
525
+ zipStorageKey: row.zip_storage_key ? String(row.zip_storage_key) : null,
526
+ zipPublicUrl: row.zip_public_url ? String(row.zip_public_url) : null,
527
+ archivedAt: row.archived_at ? String(row.archived_at) : null,
528
+ deletedAt: row.deleted_at ? String(row.deleted_at) : null,
529
+ createdAt: String(row.created_at),
530
+ updatedAt: String(row.updated_at)
531
+ };
532
+ }
533
+ function mapPostSchedule(row) {
534
+ return {
535
+ id: String(row.id),
536
+ customerId: String(row.customer_id),
537
+ readyPostId: row.ready_post_id ? String(row.ready_post_id) : null,
538
+ managedBy: row.managed_by,
539
+ provider: row.provider,
540
+ destinationType: row.destination_type,
541
+ destinationId: String(row.destination_id),
542
+ scheduledAt: String(row.scheduled_at),
543
+ timezone: row.timezone ? String(row.timezone) : null,
544
+ status: row.status,
545
+ providerStatus: row.provider_status ? String(row.provider_status) : null,
546
+ providerScheduleId: row.provider_schedule_id ? String(row.provider_schedule_id) : null,
547
+ title: row.title ? String(row.title) : null,
548
+ summary: row.summary ? String(row.summary) : null,
549
+ url: row.url ? String(row.url) : null,
550
+ additionalNotes: row.additional_notes ? String(row.additional_notes) : null,
551
+ providerPayload: parseJson(row.provider_payload_json, {}),
552
+ providerResponse: parseJson(row.provider_response_json, {}),
553
+ lastSyncedAt: row.last_synced_at ? String(row.last_synced_at) : null,
554
+ lastError: row.last_error ? String(row.last_error) : null,
555
+ cancelledAt: row.cancelled_at ? String(row.cancelled_at) : null,
556
+ sentAt: row.sent_at ? String(row.sent_at) : null,
557
+ createdAt: String(row.created_at),
558
+ updatedAt: String(row.updated_at)
559
+ };
560
+ }
561
+ function mapChatThread(row, tracers, messages = []) {
562
+ return {
563
+ id: String(row.id),
564
+ customerId: String(row.customer_id),
565
+ templateId: String(row.template_id),
566
+ title: String(row.title),
567
+ createdAt: String(row.created_at),
568
+ updatedAt: String(row.updated_at),
569
+ lastMessageAt: row.last_message_at ? String(row.last_message_at) : null,
570
+ messageCount: Number(row.message_count ?? 0),
571
+ tracers,
572
+ hiddenFromView: Boolean(row.hidden_from_view),
573
+ archivedAt: row.archived_at ? String(row.archived_at) : null,
574
+ storagePrefix: String(row.storage_prefix),
575
+ messages
576
+ };
577
+ }
332
578
  export const database = {
333
579
  raw: db,
334
580
  upsertCustomer(input) {
@@ -337,9 +583,11 @@ export const database = {
337
583
  db.prepare(`
338
584
  insert into customers (
339
585
  id, email, name, default_webhook_url, is_developer, is_paid_plan, about, groupchat_url, flockposter_api_key, password_hash, created_at, updated_at
586
+ , plan_tier
340
587
  )
341
588
  values (
342
589
  @id, @email, @name, @default_webhook_url, @is_developer, @is_paid_plan, @about, @groupchat_url, @flockposter_api_key, @password_hash, @created_at, @updated_at
590
+ , @plan_tier
343
591
  )
344
592
  on conflict(id) do update set
345
593
  email = excluded.email,
@@ -347,6 +595,7 @@ export const database = {
347
595
  default_webhook_url = excluded.default_webhook_url,
348
596
  is_developer = excluded.is_developer,
349
597
  is_paid_plan = excluded.is_paid_plan,
598
+ plan_tier = excluded.plan_tier,
350
599
  about = excluded.about,
351
600
  groupchat_url = excluded.groupchat_url,
352
601
  flockposter_api_key = excluded.flockposter_api_key,
@@ -359,6 +608,7 @@ export const database = {
359
608
  default_webhook_url: input.defaultWebhookUrl ?? null,
360
609
  is_developer: input.isDeveloper === undefined ? Number(current?.is_developer ?? 0) : (input.isDeveloper ? 1 : 0),
361
610
  is_paid_plan: input.isPaidPlan === undefined ? Number(current?.is_paid_plan ?? 0) : (input.isPaidPlan ? 1 : 0),
611
+ plan_tier: input.planTier === undefined ? String(current?.plan_tier ?? "starter") : input.planTier,
362
612
  about: input.about === undefined ? (current?.about ?? null) : input.about,
363
613
  groupchat_url: input.groupchatUrl === undefined ? (current?.groupchat_url ?? null) : input.groupchatUrl,
364
614
  flockposter_api_key: input.flockposterApiKey === undefined ? (current?.flockposter_api_key ?? null) : input.flockposterApiKey,
@@ -380,18 +630,42 @@ export const database = {
380
630
  defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null,
381
631
  isDeveloper: Boolean(row.is_developer),
382
632
  isPaidPlan: Boolean(row.is_paid_plan),
633
+ planTier: normalizeCustomerPlanTier(row.plan_tier),
383
634
  about: row.about ? String(row.about) : null,
384
635
  groupchatUrl: row.groupchat_url ? String(row.groupchat_url) : null,
385
- flockposterApiKey: row.flockposter_api_key ? String(row.flockposter_api_key) : null
636
+ flockposterApiKey: row.flockposter_api_key ? String(row.flockposter_api_key) : null,
637
+ createdAt: String(row.created_at),
638
+ updatedAt: String(row.updated_at)
386
639
  };
387
640
  },
388
641
  getCustomerByEmail(email) {
389
642
  const row = db.prepare(`select * from customers where lower(email) = lower(?)`).get(email);
390
643
  return row ? this.getCustomerById(String(row.id)) : null;
391
644
  },
645
+ getFirstPaidCustomer() {
646
+ const row = db.prepare(`
647
+ select id
648
+ from customers
649
+ where is_paid_plan = 1
650
+ order by created_at asc
651
+ limit 1
652
+ `).get();
653
+ return row?.id ? this.getCustomerById(String(row.id)) : null;
654
+ },
655
+ listPaidCustomers() {
656
+ const rows = db.prepare(`
657
+ select id
658
+ from customers
659
+ where is_paid_plan = 1
660
+ order by created_at asc
661
+ `).all();
662
+ return rows
663
+ .map((row) => this.getCustomerById(String(row.id)))
664
+ .filter((customer) => Boolean(customer));
665
+ },
392
666
  getCustomerAuthByEmail(email) {
393
667
  return db.prepare(`
394
- select id, email, name, default_webhook_url, is_developer, is_paid_plan, password_hash
668
+ select id, email, name, default_webhook_url, is_developer, is_paid_plan, password_hash, plan_tier, created_at, updated_at
395
669
  , about, groupchat_url, flockposter_api_key
396
670
  from customers
397
671
  where lower(email) = lower(?)
@@ -405,12 +679,36 @@ export const database = {
405
679
  where id = ?
406
680
  `).run(passwordHash, nowIso(), customerId);
407
681
  },
408
- updateCustomerPaidPlan(customerId, isPaidPlan) {
682
+ updateCustomerPaidPlan(customerId, isPaidPlan, planTier) {
683
+ const existing = this.getCustomerById(customerId);
409
684
  db.prepare(`
410
685
  update customers
411
- set is_paid_plan = ?, updated_at = ?
686
+ set is_paid_plan = ?, plan_tier = ?, updated_at = ?
412
687
  where id = ?
413
- `).run(isPaidPlan ? 1 : 0, nowIso(), customerId);
688
+ `).run(isPaidPlan ? 1 : 0, planTier ?? existing?.planTier ?? "starter", nowIso(), customerId);
689
+ },
690
+ hasCustomerCreditGrant(customerId, grantType) {
691
+ const row = db.prepare(`
692
+ select 1
693
+ from customer_credit_grants
694
+ where customer_id = ? and grant_type = ?
695
+ limit 1
696
+ `).get(customerId, grantType);
697
+ return Boolean(row);
698
+ },
699
+ insertCustomerCreditGrant(record) {
700
+ const createdAt = nowIso();
701
+ db.prepare(`
702
+ insert into customer_credit_grants (id, customer_id, grant_type, amount_usd, metadata_json, created_at)
703
+ values (@id, @customer_id, @grant_type, @amount_usd, @metadata_json, @created_at)
704
+ `).run({
705
+ id: record.id,
706
+ customer_id: record.customerId,
707
+ grant_type: record.grantType,
708
+ amount_usd: record.amountUsd,
709
+ metadata_json: stringifyJson(record.metadata ?? {}),
710
+ created_at: createdAt
711
+ });
414
712
  },
415
713
  setCustomerAccessByEmail(input) {
416
714
  const normalizedEmail = input.email.trim().toLowerCase();
@@ -422,21 +720,23 @@ export const database = {
422
720
  defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
423
721
  isDeveloper: input.isDeveloper === undefined ? (existing?.isDeveloper ?? false) : input.isDeveloper,
424
722
  isPaidPlan: input.isPaidPlan === undefined ? (existing?.isPaidPlan ?? false) : input.isPaidPlan,
723
+ planTier: input.planTier === undefined ? (existing?.planTier ?? "starter") : input.planTier,
425
724
  about: existing?.about ?? null,
426
725
  groupchatUrl: existing?.groupchatUrl ?? null,
427
726
  flockposterApiKey: existing?.flockposterApiKey ?? null
428
727
  });
429
728
  },
430
729
  updateCustomerProfile(input) {
730
+ const existing = this.getCustomerById(input.customerId);
431
731
  db.prepare(`
432
732
  update customers
433
- set about = ?, groupchat_url = ?, flockposter_api_key = ?, updated_at = ?
733
+ set name = ?, about = ?, groupchat_url = ?, flockposter_api_key = ?, updated_at = ?
434
734
  where id = ?
435
- `).run(input.about, input.groupchatUrl, input.flockposterApiKey, nowIso(), input.customerId);
735
+ `).run(input.name === undefined ? (existing?.name ?? null) : input.name, input.about === undefined ? (existing?.about ?? null) : input.about, input.groupchatUrl === undefined ? (existing?.groupchatUrl ?? null) : input.groupchatUrl, input.flockposterApiKey === undefined ? (existing?.flockposterApiKey ?? null) : input.flockposterApiKey, nowIso(), input.customerId);
436
736
  },
437
737
  listProviderKeys(customerId) {
438
738
  return db.prepare(`
439
- select id, provider, label, status, weight, last_used_at, cooldown_until, disabled_reason, created_at, updated_at
739
+ select id, provider, label, status, last_used_at, cooldown_until, disabled_reason, created_at, updated_at
440
740
  from customer_provider_keys
441
741
  where customer_id = ?
442
742
  order by provider asc, created_at desc
@@ -444,7 +744,7 @@ export const database = {
444
744
  },
445
745
  listProviderKeysWithSecrets(customerId) {
446
746
  return db.prepare(`
447
- select id, provider, label, encrypted_secret as secret, status, weight, last_used_at, cooldown_until, disabled_reason, created_at, updated_at
747
+ select id, provider, label, encrypted_secret as secret, status, last_used_at, cooldown_until, disabled_reason, created_at, updated_at
448
748
  from customer_provider_keys
449
749
  where customer_id = ?
450
750
  order by provider asc, created_at desc
@@ -453,6 +753,115 @@ export const database = {
453
753
  deleteProviderKey(customerId, keyId) {
454
754
  db.prepare(`delete from customer_provider_keys where id = ? and customer_id = ?`).run(keyId, customerId);
455
755
  },
756
+ createEmailChannel(record) {
757
+ const timestamp = nowIso();
758
+ db.prepare(`
759
+ insert into email_channels (
760
+ id, customer_id, title, email, note, avatar_url, avatar_storage_key, status, verification_token_hash,
761
+ verification_sent_at, verified_at, created_at, updated_at
762
+ ) values (
763
+ @id, @customer_id, @title, @email, @note, @avatar_url, @avatar_storage_key, @status, @verification_token_hash,
764
+ @verification_sent_at, null, @created_at, @updated_at
765
+ )
766
+ `).run({
767
+ id: record.id,
768
+ customer_id: record.customerId,
769
+ title: record.title,
770
+ email: record.email,
771
+ note: record.note ?? null,
772
+ avatar_url: record.avatarUrl ?? null,
773
+ avatar_storage_key: record.avatarStorageKey ?? null,
774
+ status: record.status,
775
+ verification_token_hash: record.verificationTokenHash ?? null,
776
+ verification_sent_at: record.verificationSentAt ?? null,
777
+ created_at: timestamp,
778
+ updated_at: timestamp
779
+ });
780
+ return this.getEmailChannel(record.customerId, record.id);
781
+ },
782
+ listEmailChannels(customerId) {
783
+ const rows = db.prepare(`
784
+ select *
785
+ from email_channels
786
+ where customer_id = ?
787
+ order by created_at desc
788
+ `).all(customerId);
789
+ return rows.map(mapEmailChannel);
790
+ },
791
+ getEmailChannel(customerId, channelId) {
792
+ const row = db.prepare(`
793
+ select *
794
+ from email_channels
795
+ where customer_id = ? and id = ?
796
+ limit 1
797
+ `).get(customerId, channelId);
798
+ return row ? mapEmailChannel(row) : null;
799
+ },
800
+ getEmailChannelByEmail(customerId, email) {
801
+ const row = db.prepare(`
802
+ select *
803
+ from email_channels
804
+ where customer_id = ? and lower(email) = lower(?)
805
+ limit 1
806
+ `).get(customerId, email);
807
+ return row ? mapEmailChannel(row) : null;
808
+ },
809
+ findEmailChannelByVerificationHash(tokenHash) {
810
+ const row = db.prepare(`
811
+ select *
812
+ from email_channels
813
+ where verification_token_hash = ?
814
+ limit 1
815
+ `).get(tokenHash);
816
+ return row ? mapEmailChannel(row) : null;
817
+ },
818
+ updateEmailChannel(input) {
819
+ db.prepare(`
820
+ update email_channels
821
+ set title = ?, note = ?, updated_at = ?
822
+ where customer_id = ? and id = ?
823
+ `).run(input.title, input.note ?? null, nowIso(), input.customerId, input.channelId);
824
+ return this.getEmailChannel(input.customerId, input.channelId);
825
+ },
826
+ updateEmailChannelAvatar(input) {
827
+ db.prepare(`
828
+ update email_channels
829
+ set avatar_url = ?, avatar_storage_key = ?, updated_at = ?
830
+ where customer_id = ? and id = ?
831
+ `).run(input.avatarUrl, input.avatarStorageKey, nowIso(), input.customerId, input.channelId);
832
+ return this.getEmailChannel(input.customerId, input.channelId);
833
+ },
834
+ updateEmailChannelVerification(input) {
835
+ db.prepare(`
836
+ update email_channels
837
+ set status = 'unverified',
838
+ verification_token_hash = ?,
839
+ verification_sent_at = ?,
840
+ verified_at = null,
841
+ updated_at = ?
842
+ where customer_id = ? and id = ?
843
+ `).run(input.tokenHash, input.sentAt, nowIso(), input.customerId, input.channelId);
844
+ return this.getEmailChannel(input.customerId, input.channelId);
845
+ },
846
+ verifyEmailChannelByTokenHash(tokenHash) {
847
+ const existing = this.findEmailChannelByVerificationHash(tokenHash);
848
+ if (!existing) {
849
+ return null;
850
+ }
851
+ const timestamp = nowIso();
852
+ db.prepare(`
853
+ update email_channels
854
+ set status = 'verified',
855
+ verification_token_hash = null,
856
+ verified_at = ?,
857
+ updated_at = ?
858
+ where id = ?
859
+ `).run(timestamp, timestamp, existing.id);
860
+ return this.getEmailChannel(existing.customerId, existing.id);
861
+ },
862
+ deleteEmailChannel(customerId, channelId) {
863
+ db.prepare(`delete from email_channels where customer_id = ? and id = ?`).run(customerId, channelId);
864
+ },
456
865
  insertOtpChallenge(record) {
457
866
  db.prepare(`
458
867
  insert into otp_challenges (id, email, code_hash, expires_at, created_at)
@@ -502,7 +911,7 @@ export const database = {
502
911
  },
503
912
  findApiKeyByHash(keyHash) {
504
913
  return db.prepare(`
505
- select a.*, c.email, c.name, c.default_webhook_url, c.is_developer, c.is_paid_plan, c.about, c.groupchat_url, c.flockposter_api_key
914
+ select a.*, c.email, c.name, c.default_webhook_url, c.is_developer, c.is_paid_plan, c.plan_tier, c.about, c.groupchat_url, c.flockposter_api_key, c.created_at, c.updated_at
506
915
  from api_keys a
507
916
  join customers c on c.id = a.customer_id
508
917
  where a.key_hash = ? and a.status = 'active'
@@ -577,6 +986,17 @@ export const database = {
577
986
  const row = db.prepare(`select * from jobs where id = ?`).get(id);
578
987
  return row ? mapJob(row) : null;
579
988
  },
989
+ getJobsByIds(ids) {
990
+ const uniqueIds = [...new Set(ids.map((id) => String(id).trim()).filter(Boolean))];
991
+ if (!uniqueIds.length) {
992
+ return [];
993
+ }
994
+ const rows = db.prepare(`
995
+ select * from jobs
996
+ where id in (${uniqueIds.map(() => "?").join(", ")})
997
+ `).all(...uniqueIds);
998
+ return rows.map(mapJob);
999
+ },
580
1000
  countJobsByStatuses(input) {
581
1001
  const statuses = input.statuses.length ? input.statuses : ["queued"];
582
1002
  const filters = [`status in (${statuses.map(() => "?").join(", ")})`];
@@ -601,11 +1021,37 @@ export const database = {
601
1021
  `).all(nowIso(), limit);
602
1022
  return rows.map(mapJob);
603
1023
  },
1024
+ listAvailableProviderKeyCounts() {
1025
+ const rows = db.prepare(`
1026
+ select
1027
+ k.customer_id,
1028
+ k.provider,
1029
+ count(*) as available_count
1030
+ from customer_provider_keys k
1031
+ left join provider_key_leases l
1032
+ on l.key_id = k.id
1033
+ and l.expires_at > ?
1034
+ where k.status = 'active'
1035
+ and (k.cooldown_until is null or k.cooldown_until <= ?)
1036
+ and l.key_id is null
1037
+ group by k.customer_id, k.provider
1038
+ `).all(nowIso(), nowIso());
1039
+ return rows.map((row) => ({
1040
+ customerId: row.customer_id,
1041
+ provider: row.provider,
1042
+ availableCount: Number(row.available_count),
1043
+ }));
1044
+ },
604
1045
  markJobRunning(id) {
605
1046
  const timestamp = nowIso();
606
1047
  db.prepare(`
607
1048
  update jobs
608
- set status = 'running', started_at = coalesce(started_at, ?), updated_at = ?, attempt_count = attempt_count + 1
1049
+ set status = 'running',
1050
+ started_at = coalesce(started_at, ?),
1051
+ error_json = null,
1052
+ completed_at = null,
1053
+ updated_at = ?,
1054
+ attempt_count = attempt_count + 1
609
1055
  where id = ?
610
1056
  `).run(timestamp, timestamp, id);
611
1057
  },
@@ -651,15 +1097,43 @@ export const database = {
651
1097
  filters.push("created_at <= ?");
652
1098
  values.push(input.endTime);
653
1099
  }
654
- values.push(Math.max(1, Math.min(input.limit ?? 100, 500)));
1100
+ if (input.cursor) {
1101
+ filters.push("(created_at < ? or (created_at = ? and id < ?))");
1102
+ values.push(input.cursor.createdAt, input.cursor.createdAt, input.cursor.id);
1103
+ }
1104
+ values.push(Math.max(1, Math.min(input.limit ?? 100, 501)));
655
1105
  const rows = db.prepare(`
656
1106
  select * from jobs
657
1107
  where ${filters.join(" and ")}
658
- order by created_at desc
1108
+ order by created_at desc, id desc
659
1109
  limit ?
660
1110
  `).all(...values);
661
1111
  return rows.map(mapJob);
662
1112
  },
1113
+ renameJobsTracer(input) {
1114
+ const fromTracer = input.fromTracer.trim();
1115
+ const toTracer = input.toTracer.trim();
1116
+ if (!fromTracer || !toTracer || fromTracer === toTracer) {
1117
+ return 0;
1118
+ }
1119
+ const filters = ["customer_id = @customer_id", "tracer = @from_tracer"];
1120
+ if (input.templateId) {
1121
+ filters.push("template_id = @template_id");
1122
+ }
1123
+ const result = db.prepare(`
1124
+ update jobs
1125
+ set tracer = @to_tracer,
1126
+ updated_at = @updated_at
1127
+ where ${filters.join(" and ")}
1128
+ `).run({
1129
+ customer_id: input.customerId,
1130
+ template_id: input.templateId ?? null,
1131
+ from_tracer: fromTracer,
1132
+ to_tracer: toTracer,
1133
+ updated_at: nowIso()
1134
+ });
1135
+ return Number(result.changes || 0);
1136
+ },
663
1137
  insertArtifact(record) {
664
1138
  db.prepare(`
665
1139
  insert into artifacts (id, job_id, customer_id, template_id, kind, storage_key, public_url, metadata_json, created_at)
@@ -676,21 +1150,26 @@ export const database = {
676
1150
  created_at: nowIso()
677
1151
  });
678
1152
  },
679
- insertBillingEvent(record) {
680
- db.prepare(`
681
- insert into billing_events (id, customer_id, job_id, template_id, type, cost_usd, charge_usd, metadata_json, created_at)
682
- values (@id, @customer_id, @job_id, @template_id, @type, @cost_usd, @charge_usd, @metadata_json, @created_at)
683
- `).run({
684
- id: record.id,
685
- customer_id: record.customerId,
686
- job_id: record.jobId,
687
- template_id: record.templateId,
688
- type: record.type,
689
- cost_usd: record.costUsd,
690
- charge_usd: record.chargeUsd,
691
- metadata_json: stringifyJson(record.metadata ?? {}),
692
- created_at: nowIso()
693
- });
1153
+ listArtifactsForJob(input) {
1154
+ const filters = ["job_id = ?", "customer_id = ?"];
1155
+ const values = [input.jobId, input.customerId];
1156
+ if (input.templateId) {
1157
+ filters.push("template_id = ?");
1158
+ values.push(input.templateId);
1159
+ }
1160
+ if (input.cursor) {
1161
+ filters.push("(created_at < ? or (created_at = ? and id < ?))");
1162
+ values.push(input.cursor.createdAt, input.cursor.createdAt, input.cursor.id);
1163
+ }
1164
+ values.push(Math.max(1, Math.min(input.limit ?? 100, 501)));
1165
+ const rows = db.prepare(`
1166
+ select *
1167
+ from artifacts
1168
+ where ${filters.join(" and ")}
1169
+ order by created_at desc, id desc
1170
+ limit ?
1171
+ `).all(...values);
1172
+ return rows.map(mapArtifact);
694
1173
  },
695
1174
  queueWebhookDelivery(record) {
696
1175
  const timestamp = nowIso();
@@ -743,6 +1222,28 @@ export const database = {
743
1222
  acquireProviderKeyLease(input) {
744
1223
  const transaction = db.transaction(() => {
745
1224
  db.prepare(`delete from provider_key_leases where expires_at <= ?`).run(nowIso());
1225
+ db.prepare(`delete from provider_key_waiters where expires_at <= ?`).run(nowIso());
1226
+ db.prepare(`
1227
+ insert into provider_key_waiters (id, customer_id, provider, worker_id, owner_id, priority, created_at, expires_at)
1228
+ values (?, ?, ?, ?, ?, ?, coalesce((select created_at from provider_key_waiters where id = ?), ?), ?)
1229
+ on conflict(id) do update set
1230
+ worker_id = excluded.worker_id,
1231
+ owner_id = excluded.owner_id,
1232
+ priority = excluded.priority,
1233
+ expires_at = excluded.expires_at
1234
+ `).run(input.waiterId, input.customerId, input.provider, input.workerId, input.jobId, input.priority, input.waiterId, nowIso(), input.waiterExpiresAt);
1235
+ const nextWaiter = db.prepare(`
1236
+ select id
1237
+ from provider_key_waiters
1238
+ where customer_id = ?
1239
+ and provider = ?
1240
+ and expires_at > ?
1241
+ order by priority asc, created_at asc
1242
+ limit 1
1243
+ `).get(input.customerId, input.provider, nowIso());
1244
+ if (!nextWaiter || nextWaiter.id !== input.waiterId) {
1245
+ return null;
1246
+ }
746
1247
  const row = db.prepare(`
747
1248
  select k.id, k.encrypted_secret
748
1249
  from customer_provider_keys k
@@ -752,7 +1253,7 @@ export const database = {
752
1253
  and k.status = 'active'
753
1254
  and (k.cooldown_until is null or k.cooldown_until <= ?)
754
1255
  and l.key_id is null
755
- order by case when k.last_used_at is null then 0 else 1 end asc, k.last_used_at asc, k.weight desc
1256
+ order by case when k.last_used_at is null then 0 else 1 end asc, k.last_used_at asc
756
1257
  limit 1
757
1258
  `).get(nowIso(), input.customerId, input.provider, nowIso());
758
1259
  if (!row) {
@@ -762,10 +1263,14 @@ export const database = {
762
1263
  insert into provider_key_leases (key_id, lease_token, worker_id, job_id, leased_at, expires_at)
763
1264
  values (?, ?, ?, ?, ?, ?)
764
1265
  `).run(row.id, input.leaseToken, input.workerId, input.jobId, nowIso(), input.expiresAt);
1266
+ db.prepare(`delete from provider_key_waiters where id = ?`).run(input.waiterId);
765
1267
  return { keyId: row.id, encryptedSecret: row.encrypted_secret };
766
1268
  });
767
1269
  return transaction();
768
1270
  },
1271
+ deleteProviderKeyWaiter(waiterId) {
1272
+ db.prepare(`delete from provider_key_waiters where id = ?`).run(waiterId);
1273
+ },
769
1274
  releaseProviderKeyLease(input) {
770
1275
  db.prepare(`delete from provider_key_leases where key_id = ? and lease_token = ?`).run(input.keyId, input.leaseToken);
771
1276
  },
@@ -804,9 +1309,9 @@ export const database = {
804
1309
  const timestamp = nowIso();
805
1310
  db.prepare(`
806
1311
  insert into customer_provider_keys (
807
- id, customer_id, provider, label, encrypted_secret, status, weight, created_at, updated_at
1312
+ id, customer_id, provider, label, encrypted_secret, status, created_at, updated_at
808
1313
  ) values (
809
- @id, @customer_id, @provider, @label, @encrypted_secret, 'active', @weight, @created_at, @updated_at
1314
+ @id, @customer_id, @provider, @label, @encrypted_secret, 'active', @created_at, @updated_at
810
1315
  )
811
1316
  `).run({
812
1317
  id: record.id,
@@ -814,7 +1319,6 @@ export const database = {
814
1319
  provider: record.provider,
815
1320
  label: record.label,
816
1321
  encrypted_secret: record.encryptedSecret,
817
- weight: record.weight,
818
1322
  created_at: timestamp,
819
1323
  updated_at: timestamp
820
1324
  });
@@ -858,6 +1362,281 @@ export const database = {
858
1362
  deleteUserAttachment(customerId, attachmentId) {
859
1363
  db.prepare(`delete from user_attachments where customer_id = ? and id = ?`).run(customerId, attachmentId);
860
1364
  },
1365
+ createReadyPost(record) {
1366
+ const timestamp = nowIso();
1367
+ db.prepare(`
1368
+ insert into ready_posts (
1369
+ id, customer_id, status, tracer, title, content, pinned_comment, scheduled_at, timezone, post_type,
1370
+ integration_ids_json, platform_targets_json, media_assets_json, renditions_json,
1371
+ flockposter_payload_json, field_values_json, share_password_hash, zip_storage_key,
1372
+ zip_public_url, archived_at, deleted_at, created_at, updated_at
1373
+ ) values (
1374
+ @id, @customer_id, @status, @tracer, @title, @content, @pinned_comment, @scheduled_at, @timezone, @post_type,
1375
+ @integration_ids_json, @platform_targets_json, @media_assets_json, @renditions_json,
1376
+ @flockposter_payload_json, @field_values_json, @share_password_hash, @zip_storage_key,
1377
+ @zip_public_url, @archived_at, @deleted_at, @created_at, @updated_at
1378
+ )
1379
+ `).run({
1380
+ id: record.id,
1381
+ customer_id: record.customerId,
1382
+ status: record.status,
1383
+ tracer: record.tracer,
1384
+ title: record.title,
1385
+ content: record.caption,
1386
+ pinned_comment: record.pinnedComment,
1387
+ scheduled_at: null,
1388
+ timezone: null,
1389
+ post_type: "draft",
1390
+ integration_ids_json: stringifyJson([]),
1391
+ platform_targets_json: stringifyJson([]),
1392
+ media_assets_json: stringifyJson(record.mediaAssets),
1393
+ renditions_json: stringifyJson([]),
1394
+ flockposter_payload_json: stringifyJson({}),
1395
+ field_values_json: stringifyJson({}),
1396
+ share_password_hash: record.sharePasswordHash,
1397
+ zip_storage_key: record.zipStorageKey ?? null,
1398
+ zip_public_url: record.zipPublicUrl ?? null,
1399
+ archived_at: record.archivedAt ?? null,
1400
+ deleted_at: record.deletedAt ?? null,
1401
+ created_at: timestamp,
1402
+ updated_at: timestamp
1403
+ });
1404
+ return this.getReadyPost(record.id);
1405
+ },
1406
+ getReadyPost(id) {
1407
+ const row = db.prepare(`select * from ready_posts where id = ? limit 1`).get(id);
1408
+ return row ? mapReadyPost(row) : null;
1409
+ },
1410
+ listReadyPosts(customerId, input) {
1411
+ const filters = ["customer_id = ?", "status != 'deleted'"];
1412
+ const values = [customerId];
1413
+ if (input?.cursor) {
1414
+ filters.push("(created_at < ? or (created_at = ? and id < ?))");
1415
+ values.push(input.cursor.createdAt, input.cursor.createdAt, input.cursor.id);
1416
+ }
1417
+ const rows = db.prepare(`
1418
+ select *
1419
+ from ready_posts
1420
+ where ${filters.join(" and ")}
1421
+ order by created_at desc, id desc
1422
+ ${input?.limit || input?.cursor ? "limit ?" : ""}
1423
+ `).all(...(input?.limit || input?.cursor ? [...values, Math.max(1, Math.min(input?.limit ?? 100, 501))] : values));
1424
+ return rows.map(mapReadyPost);
1425
+ },
1426
+ archiveReadyPost(customerId, postId) {
1427
+ const timestamp = nowIso();
1428
+ db.prepare(`
1429
+ update ready_posts
1430
+ set status = 'archived', archived_at = coalesce(archived_at, ?), updated_at = ?
1431
+ where id = ? and customer_id = ? and status != 'deleted'
1432
+ `).run(timestamp, timestamp, postId, customerId);
1433
+ return this.getReadyPost(postId);
1434
+ },
1435
+ deleteReadyPost(customerId, postId) {
1436
+ const timestamp = nowIso();
1437
+ db.prepare(`
1438
+ update ready_posts
1439
+ set status = 'deleted', deleted_at = coalesce(deleted_at, ?), updated_at = ?
1440
+ where id = ? and customer_id = ?
1441
+ `).run(timestamp, timestamp, postId, customerId);
1442
+ return this.getReadyPost(postId);
1443
+ },
1444
+ setReadyPostZip(input) {
1445
+ db.prepare(`
1446
+ update ready_posts
1447
+ set zip_storage_key = ?, zip_public_url = ?, updated_at = ?
1448
+ where id = ? and customer_id = ? and status != 'deleted'
1449
+ `).run(input.storageKey, input.publicUrl, nowIso(), input.postId, input.customerId);
1450
+ return this.getReadyPost(input.postId);
1451
+ },
1452
+ createPostSchedule(record) {
1453
+ const timestamp = nowIso();
1454
+ db.prepare(`
1455
+ insert into post_schedules (
1456
+ id, customer_id, ready_post_id, managed_by, provider, destination_type, destination_id,
1457
+ scheduled_at, timezone, status, provider_status, provider_schedule_id, title, summary, url,
1458
+ additional_notes, provider_payload_json, provider_response_json, last_synced_at, last_error, cancelled_at, sent_at,
1459
+ created_at, updated_at
1460
+ ) values (
1461
+ @id, @customer_id, @ready_post_id, @managed_by, @provider, @destination_type, @destination_id,
1462
+ @scheduled_at, @timezone, @status, @provider_status, @provider_schedule_id, @title, @summary, @url,
1463
+ @additional_notes, @provider_payload_json, @provider_response_json, @last_synced_at, @last_error, @cancelled_at, @sent_at,
1464
+ @created_at, @updated_at
1465
+ )
1466
+ `).run({
1467
+ id: record.id,
1468
+ customer_id: record.customerId,
1469
+ ready_post_id: record.readyPostId,
1470
+ managed_by: record.managedBy,
1471
+ provider: record.provider,
1472
+ destination_type: record.destinationType,
1473
+ destination_id: record.destinationId,
1474
+ scheduled_at: record.scheduledAt,
1475
+ timezone: record.timezone,
1476
+ status: record.status,
1477
+ provider_status: record.providerStatus,
1478
+ provider_schedule_id: record.providerScheduleId,
1479
+ title: record.title,
1480
+ summary: record.summary,
1481
+ url: record.url,
1482
+ additional_notes: record.additionalNotes,
1483
+ provider_payload_json: stringifyJson(record.providerPayload),
1484
+ provider_response_json: stringifyJson(record.providerResponse),
1485
+ last_synced_at: record.lastSyncedAt,
1486
+ last_error: record.lastError,
1487
+ cancelled_at: record.cancelledAt,
1488
+ sent_at: record.sentAt,
1489
+ created_at: timestamp,
1490
+ updated_at: timestamp
1491
+ });
1492
+ if (record.readyPostId) {
1493
+ this.refreshReadyPostScheduleStatus(record.customerId, record.readyPostId);
1494
+ }
1495
+ return this.getPostSchedule(record.customerId, record.id);
1496
+ },
1497
+ getPostSchedule(customerId, scheduleId) {
1498
+ const row = db.prepare(`
1499
+ select *
1500
+ from post_schedules
1501
+ where customer_id = ? and id = ?
1502
+ limit 1
1503
+ `).get(customerId, scheduleId);
1504
+ return row ? mapPostSchedule(row) : null;
1505
+ },
1506
+ listPostSchedulesForPost(customerId, postId) {
1507
+ const rows = db.prepare(`
1508
+ select *
1509
+ from post_schedules
1510
+ where customer_id = ? and ready_post_id = ?
1511
+ order by scheduled_at asc, created_at asc
1512
+ `).all(customerId, postId);
1513
+ return rows.map(mapPostSchedule);
1514
+ },
1515
+ listPostSchedulesForCalendar(input) {
1516
+ const rows = db.prepare(`
1517
+ select *
1518
+ from post_schedules
1519
+ where customer_id = ?
1520
+ and scheduled_at >= ?
1521
+ and scheduled_at <= ?
1522
+ and status != 'cancelled'
1523
+ order by scheduled_at asc, created_at asc
1524
+ `).all(input.customerId, input.startDate, input.endDate);
1525
+ return rows.map(mapPostSchedule);
1526
+ },
1527
+ updatePostScheduleProvider(input) {
1528
+ db.prepare(`
1529
+ update post_schedules
1530
+ set scheduled_at = ?,
1531
+ timezone = ?,
1532
+ status = ?,
1533
+ provider_status = ?,
1534
+ provider_schedule_id = ?,
1535
+ provider_payload_json = ?,
1536
+ provider_response_json = ?,
1537
+ last_synced_at = ?,
1538
+ last_error = ?,
1539
+ url = coalesce(?, url),
1540
+ additional_notes = ?,
1541
+ cancelled_at = null,
1542
+ updated_at = ?
1543
+ where customer_id = ? and id = ?
1544
+ `).run(input.scheduledAt, input.timezone ?? null, input.status ?? "scheduled", input.providerStatus ?? null, input.providerScheduleId ?? null, stringifyJson(input.providerPayload), stringifyJson(input.providerResponse), nowIso(), input.lastError ?? null, input.url ?? null, input.additionalNotes ?? null, nowIso(), input.customerId, input.scheduleId);
1545
+ const updated = this.getPostSchedule(input.customerId, input.scheduleId);
1546
+ if (updated?.readyPostId) {
1547
+ this.refreshReadyPostScheduleStatus(input.customerId, updated.readyPostId);
1548
+ }
1549
+ return updated;
1550
+ },
1551
+ markPostScheduleCancelled(input) {
1552
+ const timestamp = nowIso();
1553
+ const existing = this.getPostSchedule(input.customerId, input.scheduleId);
1554
+ db.prepare(`
1555
+ update post_schedules
1556
+ set status = 'cancelled',
1557
+ provider_response_json = ?,
1558
+ last_synced_at = ?,
1559
+ last_error = ?,
1560
+ cancelled_at = coalesce(cancelled_at, ?),
1561
+ updated_at = ?
1562
+ where customer_id = ? and id = ?
1563
+ `).run(stringifyJson(input.providerResponse ?? existing?.providerResponse ?? {}), timestamp, input.lastError ?? null, timestamp, timestamp, input.customerId, input.scheduleId);
1564
+ if (existing?.readyPostId) {
1565
+ this.refreshReadyPostScheduleStatus(input.customerId, existing.readyPostId);
1566
+ }
1567
+ return this.getPostSchedule(input.customerId, input.scheduleId);
1568
+ },
1569
+ markPostScheduleFailed(input) {
1570
+ const existing = this.getPostSchedule(input.customerId, input.scheduleId);
1571
+ db.prepare(`
1572
+ update post_schedules
1573
+ set status = 'failed',
1574
+ provider_response_json = ?,
1575
+ last_synced_at = ?,
1576
+ last_error = ?,
1577
+ updated_at = ?
1578
+ where customer_id = ? and id = ?
1579
+ `).run(stringifyJson(input.providerResponse ?? existing?.providerResponse ?? {}), nowIso(), input.lastError, nowIso(), input.customerId, input.scheduleId);
1580
+ if (existing?.readyPostId) {
1581
+ this.refreshReadyPostScheduleStatus(input.customerId, existing.readyPostId);
1582
+ }
1583
+ return this.getPostSchedule(input.customerId, input.scheduleId);
1584
+ },
1585
+ upsertExternalPostSchedule(record) {
1586
+ const timestamp = nowIso();
1587
+ const existing = record.providerScheduleId
1588
+ ? db.prepare(`
1589
+ select *
1590
+ from post_schedules
1591
+ where customer_id = ? and provider = ? and provider_schedule_id = ?
1592
+ limit 1
1593
+ `).get(record.customerId, record.provider, record.providerScheduleId)
1594
+ : undefined;
1595
+ if (existing) {
1596
+ db.prepare(`
1597
+ update post_schedules
1598
+ set destination_id = ?,
1599
+ scheduled_at = ?,
1600
+ status = ?,
1601
+ provider_status = ?,
1602
+ title = ?,
1603
+ summary = ?,
1604
+ url = ?,
1605
+ additional_notes = ?,
1606
+ provider_payload_json = ?,
1607
+ provider_response_json = ?,
1608
+ last_synced_at = ?,
1609
+ last_error = null,
1610
+ updated_at = ?
1611
+ where id = ?
1612
+ `).run(record.destinationId, record.scheduledAt, record.status, record.providerStatus, record.title, record.summary, record.url, record.additionalNotes ?? null, stringifyJson(record.providerPayload), stringifyJson(record.providerResponse), timestamp, timestamp, String(existing.id));
1613
+ return this.getPostSchedule(record.customerId, String(existing.id));
1614
+ }
1615
+ return this.createPostSchedule({
1616
+ ...record,
1617
+ readyPostId: null,
1618
+ managedBy: "external",
1619
+ timezone: null,
1620
+ additionalNotes: record.additionalNotes ?? null,
1621
+ cancelledAt: null,
1622
+ sentAt: null,
1623
+ lastError: null
1624
+ });
1625
+ },
1626
+ refreshReadyPostScheduleStatus(customerId, postId) {
1627
+ const row = db.prepare(`
1628
+ select count(*) as count
1629
+ from post_schedules
1630
+ where customer_id = ? and ready_post_id = ? and status = 'scheduled'
1631
+ `).get(customerId, postId);
1632
+ const nextStatus = row && Number(row.count) > 0 ? "scheduled" : "ready";
1633
+ db.prepare(`
1634
+ update ready_posts
1635
+ set status = ?, updated_at = ?
1636
+ where customer_id = ? and id = ? and status in ('ready', 'scheduled')
1637
+ `).run(nextStatus, nowIso(), customerId, postId);
1638
+ return this.getReadyPost(postId);
1639
+ },
861
1640
  createTemplateSource(record) {
862
1641
  const timestamp = nowIso();
863
1642
  db.prepare(`
@@ -932,10 +1711,24 @@ export const database = {
932
1711
  `).get(repoUrl, branch, templateModulePath);
933
1712
  return row ? mapTemplateSource(row) : null;
934
1713
  },
935
- listTemplateSources(status) {
936
- const rows = status
937
- ? db.prepare(`select * from template_sources where status = ? order by created_at asc`).all(status)
938
- : db.prepare(`select * from template_sources order by created_at asc`).all();
1714
+ listTemplateSources(input) {
1715
+ const filters = [];
1716
+ const values = [];
1717
+ if (input?.status) {
1718
+ filters.push("status = ?");
1719
+ values.push(input.status);
1720
+ }
1721
+ if (input?.cursor) {
1722
+ filters.push("(created_at < ? or (created_at = ? and id < ?))");
1723
+ values.push(input.cursor.createdAt, input.cursor.createdAt, input.cursor.id);
1724
+ }
1725
+ const rows = db.prepare(`
1726
+ select *
1727
+ from template_sources
1728
+ ${filters.length ? `where ${filters.join(" and ")}` : ""}
1729
+ order by created_at desc, id desc
1730
+ ${input?.limit || input?.cursor ? "limit ?" : ""}
1731
+ `).all(...(input?.limit || input?.cursor ? [...values, Math.max(1, Math.min(input?.limit ?? 100, 501))] : values));
939
1732
  return rows.map(mapTemplateSource);
940
1733
  },
941
1734
  createTemplateRelease(record) {
@@ -996,15 +1789,27 @@ export const database = {
996
1789
  return row ? mapTemplateRelease(row) : null;
997
1790
  },
998
1791
  listTemplateReleases(input) {
999
- const templateId = input?.templateId;
1000
- const status = input?.status;
1001
- const rows = templateId && status
1002
- ? db.prepare(`select * from template_releases where template_id = ? and status = ? order by created_at desc`).all(templateId, status)
1003
- : templateId
1004
- ? db.prepare(`select * from template_releases where template_id = ? order by created_at desc`).all(templateId)
1005
- : status
1006
- ? db.prepare(`select * from template_releases where status = ? order by created_at desc`).all(status)
1007
- : db.prepare(`select * from template_releases order by created_at desc`).all();
1792
+ const filters = [];
1793
+ const values = [];
1794
+ if (input?.templateId) {
1795
+ filters.push("template_id = ?");
1796
+ values.push(input.templateId);
1797
+ }
1798
+ if (input?.status) {
1799
+ filters.push("status = ?");
1800
+ values.push(input.status);
1801
+ }
1802
+ if (input?.cursor) {
1803
+ filters.push("(created_at < ? or (created_at = ? and id < ?))");
1804
+ values.push(input.cursor.createdAt, input.cursor.createdAt, input.cursor.id);
1805
+ }
1806
+ const rows = db.prepare(`
1807
+ select *
1808
+ from template_releases
1809
+ ${filters.length ? `where ${filters.join(" and ")}` : ""}
1810
+ order by created_at desc, id desc
1811
+ ${input?.limit || input?.cursor ? "limit ?" : ""}
1812
+ `).all(...(input?.limit || input?.cursor ? [...values, Math.max(1, Math.min(input?.limit ?? 100, 500))] : values));
1008
1813
  return rows.map(mapTemplateRelease);
1009
1814
  },
1010
1815
  getActiveTemplateReleases() {
@@ -1040,5 +1845,163 @@ export const database = {
1040
1845
  updated_at: nowIso(),
1041
1846
  template_id: templateId
1042
1847
  });
1848
+ },
1849
+ upsertChatThread(input) {
1850
+ const timestamp = nowIso();
1851
+ const current = this.getChatThreadMeta(input.customerId, input.id);
1852
+ db.prepare(`
1853
+ insert into chat_threads (
1854
+ id, customer_id, template_id, title, storage_prefix, message_count, last_message_at,
1855
+ hidden_from_view, archived_at, created_at, updated_at
1856
+ ) values (
1857
+ @id, @customer_id, @template_id, @title, @storage_prefix, @message_count, @last_message_at,
1858
+ @hidden_from_view, @archived_at, @created_at, @updated_at
1859
+ )
1860
+ on conflict(id) do update set
1861
+ template_id = excluded.template_id,
1862
+ title = excluded.title,
1863
+ storage_prefix = excluded.storage_prefix,
1864
+ message_count = excluded.message_count,
1865
+ last_message_at = excluded.last_message_at,
1866
+ hidden_from_view = excluded.hidden_from_view,
1867
+ archived_at = excluded.archived_at,
1868
+ updated_at = excluded.updated_at
1869
+ `).run({
1870
+ id: input.id,
1871
+ customer_id: input.customerId,
1872
+ template_id: input.templateId,
1873
+ title: input.title,
1874
+ storage_prefix: input.storagePrefix,
1875
+ message_count: input.messageCount ?? current?.messageCount ?? 0,
1876
+ last_message_at: input.lastMessageAt ?? current?.lastMessageAt ?? null,
1877
+ hidden_from_view: input.hiddenFromView === undefined ? (current?.hiddenFromView ? 1 : 0) : (input.hiddenFromView ? 1 : 0),
1878
+ archived_at: input.archivedAt === undefined ? (current?.archivedAt ?? null) : input.archivedAt,
1879
+ created_at: current?.createdAt ?? timestamp,
1880
+ updated_at: timestamp
1881
+ });
1882
+ if (input.tracers) {
1883
+ this.replaceChatThreadTracers(input.id, input.tracers);
1884
+ }
1885
+ return this.getChatThreadMeta(input.customerId, input.id);
1886
+ },
1887
+ appendChatThreadMessages(input) {
1888
+ const current = this.getChatThreadMeta(input.customerId, input.threadId);
1889
+ this.upsertChatThread({
1890
+ id: input.threadId,
1891
+ customerId: input.customerId,
1892
+ templateId: input.templateId,
1893
+ title: input.title || current?.title || "New chat",
1894
+ storagePrefix: input.storagePrefix,
1895
+ tracers: input.tracers,
1896
+ hiddenFromView: current?.hiddenFromView ?? false,
1897
+ archivedAt: current?.archivedAt ?? null,
1898
+ messageCount: (current?.messageCount ?? 0) + Math.max(0, input.messageCountDelta),
1899
+ lastMessageAt: input.lastMessageAt ?? current?.lastMessageAt ?? null
1900
+ });
1901
+ return this.getChatThreadMeta(input.customerId, input.threadId);
1902
+ },
1903
+ getChatThreadMeta(customerId, threadId) {
1904
+ const row = db.prepare(`
1905
+ select *
1906
+ from chat_threads
1907
+ where customer_id = ? and id = ?
1908
+ limit 1
1909
+ `).get(customerId, threadId);
1910
+ if (!row) {
1911
+ return null;
1912
+ }
1913
+ const tracers = db.prepare(`
1914
+ select tracer
1915
+ from chat_thread_tracers
1916
+ where thread_id = ?
1917
+ order by created_at asc, tracer asc
1918
+ `).all(threadId);
1919
+ return mapChatThread(row, tracers.map((entry) => String(entry.tracer)));
1920
+ },
1921
+ listChatThreadMetas(input) {
1922
+ const filters = ["customer_id = ?"];
1923
+ const values = [input.customerId];
1924
+ if (input.templateId) {
1925
+ filters.push("template_id = ?");
1926
+ values.push(input.templateId);
1927
+ }
1928
+ if (input.cursor) {
1929
+ filters.push("(updated_at < ? or (updated_at = ? and id < ?))");
1930
+ values.push(input.cursor.updatedAt, input.cursor.updatedAt, input.cursor.id);
1931
+ }
1932
+ values.push(Math.max(1, Math.min(input.limit ?? 100, 501)));
1933
+ const rows = db.prepare(`
1934
+ select *
1935
+ from chat_threads
1936
+ where ${filters.join(" and ")}
1937
+ order by updated_at desc, id desc
1938
+ limit ?
1939
+ `).all(...values);
1940
+ if (!rows.length) {
1941
+ return [];
1942
+ }
1943
+ const tracerRows = db.prepare(`
1944
+ select thread_id, tracer
1945
+ from chat_thread_tracers
1946
+ where thread_id in (${rows.map(() => "?").join(", ")})
1947
+ order by created_at asc, tracer asc
1948
+ `).all(...rows.map((row) => String(row.id)));
1949
+ const tracerMap = new Map();
1950
+ for (const row of tracerRows) {
1951
+ const list = tracerMap.get(String(row.thread_id)) ?? [];
1952
+ list.push(String(row.tracer));
1953
+ tracerMap.set(String(row.thread_id), list);
1954
+ }
1955
+ return rows.map((row) => mapChatThread(row, tracerMap.get(String(row.id)) ?? []));
1956
+ },
1957
+ replaceChatThreadTracers(threadId, tracers) {
1958
+ const normalized = [...new Set(tracers.map((value) => value.trim()).filter(Boolean))];
1959
+ const timestamp = nowIso();
1960
+ const transaction = db.transaction(() => {
1961
+ db.prepare(`delete from chat_thread_tracers where thread_id = ?`).run(threadId);
1962
+ const insert = db.prepare(`
1963
+ insert into chat_thread_tracers (thread_id, tracer, created_at)
1964
+ values (?, ?, ?)
1965
+ `);
1966
+ for (const tracer of normalized) {
1967
+ insert.run(threadId, tracer, timestamp);
1968
+ }
1969
+ });
1970
+ transaction();
1971
+ },
1972
+ updateChatThreadState(input) {
1973
+ const current = this.getChatThreadMeta(input.customerId, input.threadId);
1974
+ if (!current) {
1975
+ return null;
1976
+ }
1977
+ db.prepare(`
1978
+ update chat_threads
1979
+ set title = ?,
1980
+ hidden_from_view = ?,
1981
+ archived_at = ?,
1982
+ updated_at = ?
1983
+ where customer_id = ? and id = ?
1984
+ `).run(input.title ?? current.title, input.hiddenFromView === undefined ? (current.hiddenFromView ? 1 : 0) : (input.hiddenFromView ? 1 : 0), input.archivedAt === undefined ? current.archivedAt : input.archivedAt, nowIso(), input.customerId, input.threadId);
1985
+ if (input.tracers) {
1986
+ this.replaceChatThreadTracers(input.threadId, input.tracers);
1987
+ }
1988
+ return this.getChatThreadMeta(input.customerId, input.threadId);
1989
+ },
1990
+ deleteChatThread(customerId, threadId) {
1991
+ const current = this.getChatThreadMeta(customerId, threadId);
1992
+ if (!current) {
1993
+ return null;
1994
+ }
1995
+ db.prepare(`
1996
+ delete from chat_threads
1997
+ where customer_id = ? and id = ?
1998
+ `).run(customerId, threadId);
1999
+ return current;
1043
2000
  }
1044
2001
  };
2002
+ function normalizeCustomerPlanTier(value) {
2003
+ if (value === "starter" || value === "standard" || value === "agency") {
2004
+ return value;
2005
+ }
2006
+ return "starter";
2007
+ }