@mevdragon/vidfarm-devcli 0.2.7 → 0.2.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/.env.example +9 -0
  2. package/GETTING_STARTED.developers.md +1 -30
  3. package/README.md +22 -5
  4. package/SKILL.developer.md +464 -12
  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 +7173 -494
  14. package/dist/src/cli.js +525 -29
  15. package/dist/src/config.js +14 -7
  16. package/dist/src/context.js +51 -35
  17. package/dist/src/db.js +1049 -55
  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 -339
  32. package/dist/src/lib/template-paths.js +10 -4
  33. package/dist/src/lib/template-style-options.js +95 -15
  34. package/dist/src/page-runtime.js +1 -0
  35. package/dist/src/page-shell.js +941 -0
  36. package/dist/src/primitive-context.js +163 -0
  37. package/dist/src/primitive-registry.js +340 -0
  38. package/dist/src/primitive-sdk.js +3 -0
  39. package/dist/src/primitives/remotion/html-image.js +28 -0
  40. package/dist/src/react-page-shell.js +34 -0
  41. package/dist/src/ready-post-schedule-component.js +1514 -0
  42. package/dist/src/registry.js +44 -18
  43. package/dist/src/runtime.js +6 -1
  44. package/dist/src/services/api-call-history.js +245 -0
  45. package/dist/src/services/auth.js +27 -9
  46. package/dist/src/services/billing.js +248 -9
  47. package/dist/src/services/chat-threads.js +88 -0
  48. package/dist/src/services/job-logs.js +10 -3
  49. package/dist/src/services/jobs.js +13 -2
  50. package/dist/src/services/providers.js +944 -55
  51. package/dist/src/services/rate-limits.js +236 -0
  52. package/dist/src/services/remotion.js +143 -16
  53. package/dist/src/services/storage.js +23 -8
  54. package/dist/src/services/template-certification.js +26 -3
  55. package/dist/src/services/template-loader.js +19 -1
  56. package/dist/src/services/template-sources.js +316 -7
  57. package/dist/src/template-editor-pages.js +2309 -0
  58. package/dist/src/template-editor-shell.js +1507 -0
  59. package/dist/src/worker.js +120 -22
  60. package/package.json +30 -6
  61. package/public/assets/homepage-app.js +54 -0
  62. package/public/assets/homepage-client-app.js +54 -0
  63. package/public/assets/page-runtime-client-app.js +68 -0
  64. package/dist/templates/template_0000/src/lib/images.js +0 -202
  65. package/dist/templates/template_0000/src/remotion/Root.js +0 -33
  66. package/dist/templates/template_0000/src/remotion/index.js +0 -3
  67. package/dist/templates/template_0000/src/sdk.js +0 -3
  68. package/dist/templates/template_0000/src/style-options.js +0 -51
  69. package/dist/templates/template_0000/src/template-dna.js +0 -9
  70. package/dist/templates/template_0000/src/template.js +0 -1441
  71. package/templates/template_0000/README.md +0 -77
  72. package/templates/template_0000/SKILL.md +0 -225
  73. package/templates/template_0000/assets/Abel-Regular.ttf +0 -0
  74. package/templates/template_0000/assets/DMSerifDisplay-Regular.ttf +0 -0
  75. package/templates/template_0000/assets/Montserrat[wght].ttf +0 -0
  76. package/templates/template_0000/assets/SourceCodePro[wght].ttf +0 -0
  77. package/templates/template_0000/assets/TikTokSans-SemiBold.ttf +0 -0
  78. package/templates/template_0000/assets/Yesteryear-Regular.ttf +0 -0
  79. package/templates/template_0000/composition.json +0 -11
  80. package/templates/template_0000/package.json +0 -28
  81. package/templates/template_0000/research/preview/.gitkeep +0 -1
  82. package/templates/template_0000/research/source_notes.md +0 -7
  83. package/templates/template_0000/src/lib/images.js +0 -202
  84. package/templates/template_0000/src/lib/images.ts +0 -241
  85. package/templates/template_0000/src/remotion/Root.js +0 -33
  86. package/templates/template_0000/src/remotion/Root.tsx +0 -75
  87. package/templates/template_0000/src/remotion/index.js +0 -3
  88. package/templates/template_0000/src/remotion/index.tsx +0 -4
  89. package/templates/template_0000/src/sdk.js +0 -3
  90. package/templates/template_0000/src/sdk.ts +0 -122
  91. package/templates/template_0000/src/style-options.js +0 -51
  92. package/templates/template_0000/src/style-options.ts +0 -60
  93. package/templates/template_0000/src/template-dna.js +0 -9
  94. package/templates/template_0000/src/template-dna.ts +0 -15
  95. package/templates/template_0000/src/template.js +0 -1441
  96. package/templates/template_0000/src/template.ts +0 -2042
  97. package/templates/template_0000/template.config.json +0 -21
  98. package/templates/template_0000/tsconfig.json +0 -19
package/dist/src/db.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import Database from "better-sqlite3";
2
2
  import { config } from "./config.js";
3
+ import { createId } from "./lib/ids.js";
3
4
  import { parseJson, stringifyJson } from "./lib/json.js";
4
5
  import { nowIso } from "./lib/time.js";
5
6
  const db = new Database(config.VIDFARM_DB_PATH);
@@ -13,6 +14,7 @@ create table if not exists customers (
13
14
  default_webhook_url text,
14
15
  is_developer integer not null default 0,
15
16
  is_paid_plan integer not null default 0,
17
+ plan_tier text not null default 'starter',
16
18
  about text,
17
19
  groupchat_url text,
18
20
  flockposter_api_key text,
@@ -49,7 +51,6 @@ create table if not exists customer_provider_keys (
49
51
  label text,
50
52
  encrypted_secret text not null,
51
53
  status text not null,
52
- weight integer not null default 1,
53
54
  last_used_at text,
54
55
  cooldown_until text,
55
56
  disabled_reason text,
@@ -66,6 +67,19 @@ create table if not exists provider_key_leases (
66
67
  expires_at text not null
67
68
  );
68
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
+
69
83
  create table if not exists provider_key_usage_events (
70
84
  id text primary key,
71
85
  key_id text not null references customer_provider_keys(id) on delete cascade,
@@ -128,18 +142,20 @@ create table if not exists artifacts (
128
142
  created_at text not null
129
143
  );
130
144
 
131
- create table if not exists billing_events (
145
+ drop table if exists billing_events;
146
+
147
+ create table if not exists customer_credit_grants (
132
148
  id text primary key,
133
149
  customer_id text not null references customers(id) on delete cascade,
134
- job_id text not null references jobs(id) on delete cascade,
135
- template_id text not null,
136
- type text not null,
137
- cost_usd real not null,
138
- charge_usd real not null,
150
+ grant_type text not null,
151
+ amount_usd real not null,
139
152
  metadata_json text not null,
140
- created_at text not null
153
+ created_at text not null,
154
+ unique(customer_id, grant_type)
141
155
  );
142
156
 
157
+ create index if not exists idx_customer_credit_grants_customer_created on customer_credit_grants(customer_id, created_at desc);
158
+
143
159
  create table if not exists webhook_deliveries (
144
160
  id text primary key,
145
161
  job_id text not null references jobs(id) on delete cascade,
@@ -168,6 +184,110 @@ create table if not exists user_attachments (
168
184
 
169
185
  create index if not exists idx_user_attachments_customer_created on user_attachments(customer_id, created_at desc);
170
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
+
171
291
  create table if not exists template_sources (
172
292
  id text primary key,
173
293
  template_id text not null unique,
@@ -192,6 +312,8 @@ create table if not exists template_releases (
192
312
  checkout_path text not null,
193
313
  skill_path text not null,
194
314
  module_path text not null,
315
+ artifact_manifest_key text,
316
+ artifact_key text,
195
317
  status text not null,
196
318
  certification_report_json text,
197
319
  activated_at text,
@@ -209,6 +331,16 @@ if (!customerColumns.some((column) => column.name === "is_developer")) {
209
331
  if (!customerColumns.some((column) => column.name === "is_paid_plan")) {
210
332
  db.exec(`alter table customers add column is_paid_plan integer not null default 0;`);
211
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
+ `);
212
344
  if (!customerColumns.some((column) => column.name === "password_hash")) {
213
345
  db.exec(`alter table customers add column password_hash text;`);
214
346
  }
@@ -225,6 +357,10 @@ const apiKeyColumns = db.prepare(`pragma table_info(api_keys)`).all();
225
357
  if (!apiKeyColumns.some((column) => column.name === "raw_value")) {
226
358
  db.exec(`alter table api_keys add column raw_value text;`);
227
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
+ }
228
364
  const templateSourceColumns = db.prepare(`pragma table_info(template_sources)`).all();
229
365
  if (!templateSourceColumns.some((column) => column.name === "slug_id")) {
230
366
  db.exec(`alter table template_sources add column slug_id text;`);
@@ -245,6 +381,31 @@ db.exec(`
245
381
  set status = 'pending_approval'
246
382
  where status = 'certified';
247
383
  `);
384
+ const templateReleaseColumns = db.prepare(`pragma table_info(template_releases)`).all();
385
+ if (!templateReleaseColumns.some((column) => column.name === "artifact_manifest_key")) {
386
+ db.exec(`alter table template_releases add column artifact_manifest_key text;`);
387
+ }
388
+ if (!templateReleaseColumns.some((column) => column.name === "artifact_key")) {
389
+ db.exec(`alter table template_releases add column artifact_key text;`);
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
+ }
248
409
  db.exec(`drop index if exists idx_job_events_job_time;`);
249
410
  db.exec(`drop table if exists job_events;`);
250
411
  function mapJob(row) {
@@ -298,6 +459,8 @@ function mapTemplateRelease(row) {
298
459
  checkoutPath: String(row.checkout_path),
299
460
  skillPath: String(row.skill_path),
300
461
  modulePath: String(row.module_path),
462
+ artifactManifestKey: row.artifact_manifest_key ? String(row.artifact_manifest_key) : null,
463
+ artifactKey: row.artifact_key ? String(row.artifact_key) : null,
301
464
  status: row.status,
302
465
  certificationReport: parseJson(row.certification_report_json, null),
303
466
  activatedAt: row.activated_at ? String(row.activated_at) : null,
@@ -317,6 +480,101 @@ function mapUserAttachment(row) {
317
480
  createdAt: String(row.created_at)
318
481
  };
319
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
+ }
320
578
  export const database = {
321
579
  raw: db,
322
580
  upsertCustomer(input) {
@@ -325,9 +583,11 @@ export const database = {
325
583
  db.prepare(`
326
584
  insert into customers (
327
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
328
587
  )
329
588
  values (
330
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
331
591
  )
332
592
  on conflict(id) do update set
333
593
  email = excluded.email,
@@ -335,6 +595,7 @@ export const database = {
335
595
  default_webhook_url = excluded.default_webhook_url,
336
596
  is_developer = excluded.is_developer,
337
597
  is_paid_plan = excluded.is_paid_plan,
598
+ plan_tier = excluded.plan_tier,
338
599
  about = excluded.about,
339
600
  groupchat_url = excluded.groupchat_url,
340
601
  flockposter_api_key = excluded.flockposter_api_key,
@@ -347,6 +608,7 @@ export const database = {
347
608
  default_webhook_url: input.defaultWebhookUrl ?? null,
348
609
  is_developer: input.isDeveloper === undefined ? Number(current?.is_developer ?? 0) : (input.isDeveloper ? 1 : 0),
349
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,
350
612
  about: input.about === undefined ? (current?.about ?? null) : input.about,
351
613
  groupchat_url: input.groupchatUrl === undefined ? (current?.groupchat_url ?? null) : input.groupchatUrl,
352
614
  flockposter_api_key: input.flockposterApiKey === undefined ? (current?.flockposter_api_key ?? null) : input.flockposterApiKey,
@@ -368,18 +630,42 @@ export const database = {
368
630
  defaultWebhookUrl: row.default_webhook_url ? String(row.default_webhook_url) : null,
369
631
  isDeveloper: Boolean(row.is_developer),
370
632
  isPaidPlan: Boolean(row.is_paid_plan),
633
+ planTier: normalizeCustomerPlanTier(row.plan_tier),
371
634
  about: row.about ? String(row.about) : null,
372
635
  groupchatUrl: row.groupchat_url ? String(row.groupchat_url) : null,
373
- 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)
374
639
  };
375
640
  },
376
641
  getCustomerByEmail(email) {
377
642
  const row = db.prepare(`select * from customers where lower(email) = lower(?)`).get(email);
378
643
  return row ? this.getCustomerById(String(row.id)) : null;
379
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
+ },
380
666
  getCustomerAuthByEmail(email) {
381
667
  return db.prepare(`
382
- 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
383
669
  , about, groupchat_url, flockposter_api_key
384
670
  from customers
385
671
  where lower(email) = lower(?)
@@ -393,23 +679,64 @@ export const database = {
393
679
  where id = ?
394
680
  `).run(passwordHash, nowIso(), customerId);
395
681
  },
396
- updateCustomerPaidPlan(customerId, isPaidPlan) {
682
+ updateCustomerPaidPlan(customerId, isPaidPlan, planTier) {
683
+ const existing = this.getCustomerById(customerId);
397
684
  db.prepare(`
398
685
  update customers
399
- set is_paid_plan = ?, updated_at = ?
686
+ set is_paid_plan = ?, plan_tier = ?, updated_at = ?
400
687
  where id = ?
401
- `).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
+ });
712
+ },
713
+ setCustomerAccessByEmail(input) {
714
+ const normalizedEmail = input.email.trim().toLowerCase();
715
+ const existing = this.getCustomerByEmail(normalizedEmail);
716
+ return this.upsertCustomer({
717
+ id: existing?.id ?? createId("cus"),
718
+ email: normalizedEmail,
719
+ name: input.name === undefined ? (existing?.name ?? null) : input.name,
720
+ defaultWebhookUrl: existing?.defaultWebhookUrl ?? null,
721
+ isDeveloper: input.isDeveloper === undefined ? (existing?.isDeveloper ?? false) : input.isDeveloper,
722
+ isPaidPlan: input.isPaidPlan === undefined ? (existing?.isPaidPlan ?? false) : input.isPaidPlan,
723
+ planTier: input.planTier === undefined ? (existing?.planTier ?? "starter") : input.planTier,
724
+ about: existing?.about ?? null,
725
+ groupchatUrl: existing?.groupchatUrl ?? null,
726
+ flockposterApiKey: existing?.flockposterApiKey ?? null
727
+ });
402
728
  },
403
729
  updateCustomerProfile(input) {
730
+ const existing = this.getCustomerById(input.customerId);
404
731
  db.prepare(`
405
732
  update customers
406
- set about = ?, groupchat_url = ?, flockposter_api_key = ?, updated_at = ?
733
+ set name = ?, about = ?, groupchat_url = ?, flockposter_api_key = ?, updated_at = ?
407
734
  where id = ?
408
- `).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);
409
736
  },
410
737
  listProviderKeys(customerId) {
411
738
  return db.prepare(`
412
- 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
413
740
  from customer_provider_keys
414
741
  where customer_id = ?
415
742
  order by provider asc, created_at desc
@@ -417,7 +744,7 @@ export const database = {
417
744
  },
418
745
  listProviderKeysWithSecrets(customerId) {
419
746
  return db.prepare(`
420
- 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
421
748
  from customer_provider_keys
422
749
  where customer_id = ?
423
750
  order by provider asc, created_at desc
@@ -426,6 +753,115 @@ export const database = {
426
753
  deleteProviderKey(customerId, keyId) {
427
754
  db.prepare(`delete from customer_provider_keys where id = ? and customer_id = ?`).run(keyId, customerId);
428
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
+ },
429
865
  insertOtpChallenge(record) {
430
866
  db.prepare(`
431
867
  insert into otp_challenges (id, email, code_hash, expires_at, created_at)
@@ -475,7 +911,7 @@ export const database = {
475
911
  },
476
912
  findApiKeyByHash(keyHash) {
477
913
  return db.prepare(`
478
- 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
479
915
  from api_keys a
480
916
  join customers c on c.id = a.customer_id
481
917
  where a.key_hash = ? and a.status = 'active'
@@ -550,6 +986,17 @@ export const database = {
550
986
  const row = db.prepare(`select * from jobs where id = ?`).get(id);
551
987
  return row ? mapJob(row) : null;
552
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
+ },
553
1000
  countJobsByStatuses(input) {
554
1001
  const statuses = input.statuses.length ? input.statuses : ["queued"];
555
1002
  const filters = [`status in (${statuses.map(() => "?").join(", ")})`];
@@ -574,11 +1021,37 @@ export const database = {
574
1021
  `).all(nowIso(), limit);
575
1022
  return rows.map(mapJob);
576
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
+ },
577
1045
  markJobRunning(id) {
578
1046
  const timestamp = nowIso();
579
1047
  db.prepare(`
580
1048
  update jobs
581
- 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
582
1055
  where id = ?
583
1056
  `).run(timestamp, timestamp, id);
584
1057
  },
@@ -624,15 +1097,43 @@ export const database = {
624
1097
  filters.push("created_at <= ?");
625
1098
  values.push(input.endTime);
626
1099
  }
627
- 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)));
628
1105
  const rows = db.prepare(`
629
1106
  select * from jobs
630
1107
  where ${filters.join(" and ")}
631
- order by created_at desc
1108
+ order by created_at desc, id desc
632
1109
  limit ?
633
1110
  `).all(...values);
634
1111
  return rows.map(mapJob);
635
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
+ },
636
1137
  insertArtifact(record) {
637
1138
  db.prepare(`
638
1139
  insert into artifacts (id, job_id, customer_id, template_id, kind, storage_key, public_url, metadata_json, created_at)
@@ -649,21 +1150,26 @@ export const database = {
649
1150
  created_at: nowIso()
650
1151
  });
651
1152
  },
652
- insertBillingEvent(record) {
653
- db.prepare(`
654
- insert into billing_events (id, customer_id, job_id, template_id, type, cost_usd, charge_usd, metadata_json, created_at)
655
- values (@id, @customer_id, @job_id, @template_id, @type, @cost_usd, @charge_usd, @metadata_json, @created_at)
656
- `).run({
657
- id: record.id,
658
- customer_id: record.customerId,
659
- job_id: record.jobId,
660
- template_id: record.templateId,
661
- type: record.type,
662
- cost_usd: record.costUsd,
663
- charge_usd: record.chargeUsd,
664
- metadata_json: stringifyJson(record.metadata ?? {}),
665
- created_at: nowIso()
666
- });
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);
667
1173
  },
668
1174
  queueWebhookDelivery(record) {
669
1175
  const timestamp = nowIso();
@@ -716,6 +1222,28 @@ export const database = {
716
1222
  acquireProviderKeyLease(input) {
717
1223
  const transaction = db.transaction(() => {
718
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
+ }
719
1247
  const row = db.prepare(`
720
1248
  select k.id, k.encrypted_secret
721
1249
  from customer_provider_keys k
@@ -725,7 +1253,7 @@ export const database = {
725
1253
  and k.status = 'active'
726
1254
  and (k.cooldown_until is null or k.cooldown_until <= ?)
727
1255
  and l.key_id is null
728
- 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
729
1257
  limit 1
730
1258
  `).get(nowIso(), input.customerId, input.provider, nowIso());
731
1259
  if (!row) {
@@ -735,10 +1263,14 @@ export const database = {
735
1263
  insert into provider_key_leases (key_id, lease_token, worker_id, job_id, leased_at, expires_at)
736
1264
  values (?, ?, ?, ?, ?, ?)
737
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);
738
1267
  return { keyId: row.id, encryptedSecret: row.encrypted_secret };
739
1268
  });
740
1269
  return transaction();
741
1270
  },
1271
+ deleteProviderKeyWaiter(waiterId) {
1272
+ db.prepare(`delete from provider_key_waiters where id = ?`).run(waiterId);
1273
+ },
742
1274
  releaseProviderKeyLease(input) {
743
1275
  db.prepare(`delete from provider_key_leases where key_id = ? and lease_token = ?`).run(input.keyId, input.leaseToken);
744
1276
  },
@@ -777,9 +1309,9 @@ export const database = {
777
1309
  const timestamp = nowIso();
778
1310
  db.prepare(`
779
1311
  insert into customer_provider_keys (
780
- 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
781
1313
  ) values (
782
- @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
783
1315
  )
784
1316
  `).run({
785
1317
  id: record.id,
@@ -787,7 +1319,6 @@ export const database = {
787
1319
  provider: record.provider,
788
1320
  label: record.label,
789
1321
  encrypted_secret: record.encryptedSecret,
790
- weight: record.weight,
791
1322
  created_at: timestamp,
792
1323
  updated_at: timestamp
793
1324
  });
@@ -831,6 +1362,281 @@ export const database = {
831
1362
  deleteUserAttachment(customerId, attachmentId) {
832
1363
  db.prepare(`delete from user_attachments where customer_id = ? and id = ?`).run(customerId, attachmentId);
833
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
+ },
834
1640
  createTemplateSource(record) {
835
1641
  const timestamp = nowIso();
836
1642
  db.prepare(`
@@ -905,26 +1711,42 @@ export const database = {
905
1711
  `).get(repoUrl, branch, templateModulePath);
906
1712
  return row ? mapTemplateSource(row) : null;
907
1713
  },
908
- listTemplateSources(status) {
909
- const rows = status
910
- ? db.prepare(`select * from template_sources where status = ? order by created_at asc`).all(status)
911
- : 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));
912
1732
  return rows.map(mapTemplateSource);
913
1733
  },
914
1734
  createTemplateRelease(record) {
915
1735
  const timestamp = nowIso();
916
1736
  db.prepare(`
917
1737
  insert into template_releases (
918
- id, source_id, template_id, branch, commit_sha, checkout_path, skill_path, module_path, status,
1738
+ id, source_id, template_id, branch, commit_sha, checkout_path, skill_path, module_path, artifact_manifest_key, artifact_key, status,
919
1739
  certification_report_json, activated_at, created_at, updated_at
920
1740
  ) values (
921
- @id, @source_id, @template_id, @branch, @commit_sha, @checkout_path, @skill_path, @module_path, @status,
1741
+ @id, @source_id, @template_id, @branch, @commit_sha, @checkout_path, @skill_path, @module_path, @artifact_manifest_key, @artifact_key, @status,
922
1742
  @certification_report_json, @activated_at, @created_at, @updated_at
923
1743
  )
924
1744
  on conflict(source_id, commit_sha) do update set
925
1745
  checkout_path = excluded.checkout_path,
926
1746
  skill_path = excluded.skill_path,
927
1747
  module_path = excluded.module_path,
1748
+ artifact_manifest_key = excluded.artifact_manifest_key,
1749
+ artifact_key = excluded.artifact_key,
928
1750
  status = excluded.status,
929
1751
  certification_report_json = excluded.certification_report_json,
930
1752
  activated_at = excluded.activated_at,
@@ -938,6 +1760,8 @@ export const database = {
938
1760
  checkout_path: record.checkoutPath,
939
1761
  skill_path: record.skillPath,
940
1762
  module_path: record.modulePath,
1763
+ artifact_manifest_key: record.artifactManifestKey ?? null,
1764
+ artifact_key: record.artifactKey ?? null,
941
1765
  status: record.status,
942
1766
  certification_report_json: stringifyJson(record.certificationReport ?? null),
943
1767
  activated_at: record.activatedAt ?? null,
@@ -965,15 +1789,27 @@ export const database = {
965
1789
  return row ? mapTemplateRelease(row) : null;
966
1790
  },
967
1791
  listTemplateReleases(input) {
968
- const templateId = input?.templateId;
969
- const status = input?.status;
970
- const rows = templateId && status
971
- ? db.prepare(`select * from template_releases where template_id = ? and status = ? order by created_at desc`).all(templateId, status)
972
- : templateId
973
- ? db.prepare(`select * from template_releases where template_id = ? order by created_at desc`).all(templateId)
974
- : status
975
- ? db.prepare(`select * from template_releases where status = ? order by created_at desc`).all(status)
976
- : 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));
977
1813
  return rows.map(mapTemplateRelease);
978
1814
  },
979
1815
  getActiveTemplateReleases() {
@@ -1009,5 +1845,163 @@ export const database = {
1009
1845
  updated_at: nowIso(),
1010
1846
  template_id: templateId
1011
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;
1012
2000
  }
1013
2001
  };
2002
+ function normalizeCustomerPlanTier(value) {
2003
+ if (value === "starter" || value === "standard" || value === "agency") {
2004
+ return value;
2005
+ }
2006
+ return "starter";
2007
+ }