@maudecode/kanvil 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/apps/server/dist/{auth-Bm6cIseE.mjs → auth-D43XyHdK.mjs} +266 -68
  2. package/apps/server/dist/client.mjs +2 -2
  3. package/apps/server/dist/{config-CrJuk5mY.mjs → config-CQVdVhXv.mjs} +1 -1
  4. package/apps/server/dist/index.mjs +167 -45
  5. package/apps/server/dist/migrate.mjs +1 -1
  6. package/apps/server/dist/recover.mjs +3 -3
  7. package/apps/server/dist/{src-D80LNZKP.mjs → src-D70GmZ5R.mjs} +170 -28
  8. package/apps/server/drizzle/0039_intake_credential.sql +2 -0
  9. package/apps/server/drizzle/0040_upload_urls.sql +20 -0
  10. package/apps/server/drizzle/0041_project_repositories.sql +2 -0
  11. package/apps/server/drizzle/meta/0039_snapshot.json +7020 -0
  12. package/apps/server/drizzle/meta/0040_snapshot.json +7170 -0
  13. package/apps/server/drizzle/meta/0041_snapshot.json +7183 -0
  14. package/apps/server/drizzle/meta/_journal.json +21 -0
  15. package/apps/web/dist/assets/{CompositeItem-Tueq2VKR.js → CompositeItem-DLTRsada.js} +1 -1
  16. package/apps/web/dist/assets/{Trans-BgU0eWAX.js → Trans-DqX-qhvQ.js} +1 -1
  17. package/apps/web/dist/assets/admin-settings-BQzdZII5.js +1 -0
  18. package/apps/web/dist/assets/{archive-restore-CSpoH6mS.js → archive-restore-BO1qng5Y.js} +1 -1
  19. package/apps/web/dist/assets/az-AZ-SXqDBPA2.js +1 -0
  20. package/apps/web/dist/assets/{az-DUcDY8Bp.js → az-CEW7-a7J.js} +1 -1
  21. package/apps/web/dist/assets/configuration-D0zvwrXt.js +1 -0
  22. package/apps/web/dist/assets/{corner-left-up-C3OGlXIB.js → corner-left-up-Be7Z9Uhf.js} +1 -1
  23. package/apps/web/dist/assets/{de-89C1V8Ut.js → de-B-oGVMYL.js} +1 -1
  24. package/apps/web/dist/assets/de-DE-CB7aQ6b-.js +1 -0
  25. package/apps/web/dist/assets/default-CCI1vZxa.js +56 -0
  26. package/apps/web/dist/assets/delivery-settings-BrwSrFIN.js +1 -0
  27. package/apps/web/dist/assets/{el-D6CICkhi.js → el-Cp69kezM.js} +1 -1
  28. package/apps/web/dist/assets/el-GR-D1ntYXIt.js +1 -0
  29. package/apps/web/dist/assets/{ellipsis-lJJU8zB7.js → ellipsis-B0AE8ax3.js} +1 -1
  30. package/apps/web/dist/assets/{es-B20Izib2.js → es-CwOMVSTx.js} +1 -1
  31. package/apps/web/dist/assets/es-ES-CllsaxcB.js +1 -0
  32. package/apps/web/dist/assets/{forms--87R7VQW.js → forms-CkVpTx29.js} +1 -1
  33. package/apps/web/dist/assets/{fr-DVlGnGsu.js → fr-Cy4co9ic.js} +1 -1
  34. package/apps/web/dist/assets/fr-FR-DxTw6LgI.js +1 -0
  35. package/apps/web/dist/assets/{hi-D1F5Pi_I.js → hi-DbO9TKwg.js} +1 -1
  36. package/apps/web/dist/assets/hi-IN-CaQga-0l.js +1 -0
  37. package/apps/web/dist/assets/{highlight-ygDJPvRy.js → highlight-zMg1BK-r.js} +1 -1
  38. package/apps/web/dist/assets/{id-LESXKW7G.js → id-B6NKsmCm.js} +1 -1
  39. package/apps/web/dist/assets/id-ID-DBfwAAqU.js +1 -0
  40. package/apps/web/dist/assets/index-CZuFd4LM.js +17 -0
  41. package/apps/web/dist/assets/index-DPT5LAs1.css +2 -0
  42. package/apps/web/dist/assets/invitations-Dp40BfHj.js +1 -0
  43. package/apps/web/dist/assets/it-IT-BckuH9Nl.js +1 -0
  44. package/apps/web/dist/assets/{it-CgRGAs6u.js → it-ilGYCq2T.js} +1 -1
  45. package/apps/web/dist/assets/item-DQR89bKm.js +1 -0
  46. package/apps/web/dist/assets/{ja-B7arhzgX.js → ja-D-hJRTgP.js} +1 -1
  47. package/apps/web/dist/assets/ja-JP-BAGgJKZ4.js +1 -0
  48. package/apps/web/dist/assets/ko-KR-srIEsTP5.js +1 -0
  49. package/apps/web/dist/assets/{ko-Ro_UonyA.js → ko-ajkkoJ_d.js} +1 -1
  50. package/apps/web/dist/assets/label-settings-ChnKkA97.js +1 -0
  51. package/apps/web/dist/assets/{markdown-blocks-toSPlHSc.js → markdown-blocks-CpzrwyQG.js} +1 -1
  52. package/apps/web/dist/assets/member-directory-XK0ZEGUq.js +1 -0
  53. package/apps/web/dist/assets/{mk-DR3kmrIp.js → mk-C_sMyEHX.js} +1 -1
  54. package/apps/web/dist/assets/mk-MK-BTa1NMAF.js +1 -0
  55. package/apps/web/dist/assets/{nl-D3uRJJLT.js → nl-D9-P4UPR.js} +1 -1
  56. package/apps/web/dist/assets/nl-NL-CQduhDqs.js +1 -0
  57. package/apps/web/dist/assets/{pl-CtzDOLdV.js → pl-CiQM7fSB.js} +1 -1
  58. package/apps/web/dist/assets/pl-PL-BdZMf7Ni.js +1 -0
  59. package/apps/web/dist/assets/project-lifecycle-VYecJuiP.js +1 -0
  60. package/apps/web/dist/assets/project-settings-Bs6YEpo9.js +1 -0
  61. package/apps/web/dist/assets/{pt-BR-FarXHHuf.js → pt-BR-CP7dhuqF.js} +1 -1
  62. package/apps/web/dist/assets/pt-BR-DZEXJNHg.js +1 -0
  63. package/apps/web/dist/assets/public-project-DUfWDao9.js +1 -0
  64. package/apps/web/dist/assets/{rotate-ccw-Byx7O3RH.js → rotate-ccw-Cxh9ec2s.js} +1 -1
  65. package/apps/web/dist/assets/{ru-DHMEVxvB.js → ru-DxJ6vF4E.js} +1 -1
  66. package/apps/web/dist/assets/ru-RU-CZKEe7P8.js +1 -0
  67. package/apps/web/dist/assets/search-DfbsqO7M.js +85 -0
  68. package/apps/web/dist/assets/{send-DlGV2XQS.js → send-DrJl0BKm.js} +1 -1
  69. package/apps/web/dist/assets/settings-BN8uIiBG.js +1 -0
  70. package/apps/web/dist/assets/{sliders-horizontal-CEHGGft_.js → sliders-horizontal-DM47MLe7.js} +1 -1
  71. package/apps/web/dist/assets/switch-CJTVGtE9.js +1 -0
  72. package/apps/web/dist/assets/{table-2AEfwytk.js → table-BLLjFPWD.js} +1 -1
  73. package/apps/web/dist/assets/ticket-detail-BTy_3JeU.js +2 -0
  74. package/apps/web/dist/assets/ticket-editor-Bb_lyxnJ.js +2 -0
  75. package/apps/web/dist/assets/ticket-list-Botp5qf5.js +6 -0
  76. package/apps/web/dist/assets/ticket-list-controls-Dmkno4pm.js +1 -0
  77. package/apps/web/dist/assets/{tr-CjdSDu0z.js → tr-9IkdRLIk.js} +1 -1
  78. package/apps/web/dist/assets/tr-TR-Y8wBLO8I.js +1 -0
  79. package/apps/web/dist/assets/{uk-C8od6u_H.js → uk-Btampsdw.js} +1 -1
  80. package/apps/web/dist/assets/uk-UA-CVpasGUS.js +1 -0
  81. package/apps/web/dist/assets/{upload-BeTAGU_W.js → upload-HARy4VC_.js} +1 -1
  82. package/apps/web/dist/assets/{useQueries-BtRgTciJ.js → useQueries-CTeEF--U.js} +1 -1
  83. package/apps/web/dist/assets/{vi-BwdBtgfS.js → vi-BVZzOn_n.js} +1 -1
  84. package/apps/web/dist/assets/vi-VN-BMy-iJD-.js +1 -0
  85. package/apps/web/dist/assets/visual-editor-DKBPjqV-.js +208 -0
  86. package/apps/web/dist/assets/workspace-settings-DqzBNw60.js +1 -0
  87. package/apps/web/dist/assets/{zh-CN-DM-UoFmL.js → zh-CN-BoUvF2do.js} +1 -1
  88. package/apps/web/dist/assets/zh-CN-Cds_v0R8.js +1 -0
  89. package/apps/web/dist/assets/{zh-TW-q2nrwFjt.js → zh-TW-CEIDGTWe.js} +1 -1
  90. package/apps/web/dist/assets/zh-TW-DCCYakXz.js +1 -0
  91. package/apps/web/dist/index.html +9 -9
  92. package/docs/api.md +35 -5
  93. package/docs/cli.md +1 -1
  94. package/docs/mobile.md +67 -4
  95. package/docs/releasing.md +6 -4
  96. package/package.json +1 -1
  97. package/apps/web/dist/assets/admin-settings-Ckis3XJG.js +0 -1
  98. package/apps/web/dist/assets/az-AZ-bFxpMMZN.js +0 -1
  99. package/apps/web/dist/assets/de-DE-BeXI30Ll.js +0 -1
  100. package/apps/web/dist/assets/default-Dnep8Yat.js +0 -56
  101. package/apps/web/dist/assets/delivery-settings-cMldZ9K7.js +0 -1
  102. package/apps/web/dist/assets/el-GR-SgRHlWhY.js +0 -1
  103. package/apps/web/dist/assets/es-ES-BF1J5vap.js +0 -1
  104. package/apps/web/dist/assets/fr-FR-D_sldwf6.js +0 -1
  105. package/apps/web/dist/assets/hi-IN-CfOw4TWf.js +0 -1
  106. package/apps/web/dist/assets/id-ID-CLKtHO7Q.js +0 -1
  107. package/apps/web/dist/assets/index-Bn-MADyj.css +0 -2
  108. package/apps/web/dist/assets/index-Ds3IHeIi.js +0 -17
  109. package/apps/web/dist/assets/invitations-C5WbdDUQ.js +0 -1
  110. package/apps/web/dist/assets/it-IT-eVZNW5qX.js +0 -1
  111. package/apps/web/dist/assets/item-BD1Poxfs.js +0 -1
  112. package/apps/web/dist/assets/ja-JP-D96njn-P.js +0 -1
  113. package/apps/web/dist/assets/ko-KR-Bash_ieA.js +0 -1
  114. package/apps/web/dist/assets/label-settings-BaikCfal.js +0 -1
  115. package/apps/web/dist/assets/member-directory-BjUy1KuK.js +0 -1
  116. package/apps/web/dist/assets/mk-MK-BWQWGIRx.js +0 -1
  117. package/apps/web/dist/assets/nl-NL-D1tvo0Gd.js +0 -1
  118. package/apps/web/dist/assets/pl-PL-DNgGUEcQ.js +0 -1
  119. package/apps/web/dist/assets/project-lifecycle-BBHcRi6p.js +0 -1
  120. package/apps/web/dist/assets/project-settings-BaYuxYjg.js +0 -1
  121. package/apps/web/dist/assets/pt-BR-CZsUZQ6P.js +0 -1
  122. package/apps/web/dist/assets/public-project-DGf3nkg2.js +0 -1
  123. package/apps/web/dist/assets/ru-RU-4ew92YE_.js +0 -1
  124. package/apps/web/dist/assets/search-DwsPEC4E.js +0 -85
  125. package/apps/web/dist/assets/settings-C_sN8Y1h.js +0 -1
  126. package/apps/web/dist/assets/switch-B_rsZYBf.js +0 -1
  127. package/apps/web/dist/assets/ticket-detail-C2l8nwF-.js +0 -2
  128. package/apps/web/dist/assets/ticket-editor-B0UB98HS.js +0 -2
  129. package/apps/web/dist/assets/ticket-list-D_MHudj1.js +0 -6
  130. package/apps/web/dist/assets/ticket-list-controls-jKg4i-NA.js +0 -1
  131. package/apps/web/dist/assets/tr-TR-CM381XCw.js +0 -1
  132. package/apps/web/dist/assets/uk-UA-AeOiWCVo.js +0 -1
  133. package/apps/web/dist/assets/vi-VN-td08xXv9.js +0 -1
  134. package/apps/web/dist/assets/visual-editor-CzB_qwvB.js +0 -208
  135. package/apps/web/dist/assets/workspace-settings-DOr9j8X1.js +0 -1
  136. package/apps/web/dist/assets/zh-CN-D3dhEYLS.js +0 -1
  137. package/apps/web/dist/assets/zh-TW-CfhOEFo8.js +0 -1
@@ -1,11 +1,12 @@
1
- import { a as auth_schema_exports, c as oauthConsent, d as user, i as apikey, l as oauthRefreshToken, o as oauthAccessToken, r as account, s as oauthClient, u as session } from "./config-CrJuk5mY.mjs";
2
- import { $ as permissionLabels, A as hasAdminRole, At as workflowOrder, Bt as searchSettingsSchema, D as filterKey, Dt as ticketPredicates, E as eventSchema, Et as ticketImportSchema, F as isHexColor, Ft as instanceUpdateSchema, J as operationPermission, N as importSecretFields, Nt as authenticationPolicySchema, O as filterList, Ot as ticketSchema, P as invitationStatuses, Pt as brokerSettingsSchema, Q as parsePublicOutput, Rt as s3SettingsSchema, S as difficulties, St as states, T as eventLabels, Tt as ticketFilter, V as mentionScheme, X as parseCustomFieldFilter, Y as operations, Z as parseOutput, _ as deletedAccountName, _t as seededPermissions, at as publicOperations, b as destinationEventKeys, bt as stagedUploadLifetime, ct as repositoryProviderDefinitions, d as customFieldSchema, dt as resourceProvider, et as permissionScope, f as customFieldValue, ft as roleCategory, g as defaultWorkflow, gt as searchResultTypes, h as defaultOrder, ht as scopes, i as channelRouteAllows, it as projectSchema, jt as workspaceSchema, k as formatEvent, kt as ticketSummarySchema, l as credentialProjectsSchema, lt as repositoryProviders, m as defaultClaimMaxLeaseSeconds, mt as roles, n as attachmentLimit, nt as priorities, p as customFieldValueSchema, pt as roleLabels, r as channelKinds, rt as priorityLabels, s as closedStates, st as remoteKinds, t as ApiError, tt as permissions, u as customFieldFilterTokens, ut as repositorySecretFields, v as deliverySecretFields, vt as signInMethodLabels, w as eventCategories, wt as ticketExportFormat, x as destinationKinds, xt as stateLabels, y as deliverySettingsSchema, yt as snapshotSchema, z as isSeededRole, zt as searchAuthentication } from "./src-D80LNZKP.mjs";
3
- import { and, asc, count, desc, eq, getTableColumns, gt, ilike, inArray, isNotNull, isNull, lt, lte, ne, notInArray, or, sql } from "drizzle-orm";
1
+ import { a as auth_schema_exports, c as oauthConsent, d as user, i as apikey, l as oauthRefreshToken, o as oauthAccessToken, r as account, s as oauthClient, u as session } from "./config-CQVdVhXv.mjs";
2
+ import { $ as parseCustomFieldFilter, A as filterList, At as ticketImportSchema, Bt as instanceUpdateSchema, C as destinationKinds, Ct as stagedUploadLifetime, D as eventLabels, E as eventCategories, Et as states, F as importSecretFields, Ft as workflowOrder, Gt as searchSettingsSchema, I as invitationStatuses, It as workspaceSchema, L as isHexColor, M as hasAdminRole, Mt as ticketSchema, Nt as ticketSummarySchema, O as eventSchema, Ot as ticketExportFormat, Pt as uploadUrlLifetime, Q as operations, Rt as authenticationPolicySchema, S as destinationEventKeys, St as snapshotSchema, Tt as stateLabels, U as mentionScheme, Ut as s3SettingsSchema, V as isSeededRole, W as normalizeRemote, Wt as searchAuthentication, Z as operationPermission, _ as defaultOrder, _t as roles, a as channelRouteAllows, at as priorities, b as deliverySecretFields, bt as seededPermissions, c as claimSchema, ct as publicOperations, d as credentialProjectsSchema, dt as repositoryProviderDefinitions, et as parseOutput, f as customFieldFilterTokens, ft as repositoryProviders, g as defaultClaimMaxLeaseSeconds, gt as roleLabels, h as customFieldValueSchema, ht as roleCategory, i as channelKinds, it as permissions, j as formatEvent, jt as ticketPredicates, k as filterKey, kt as ticketFilter, l as closedStates, m as customFieldValue, mt as resourceProvider, n as attachmentLimit, nt as permissionLabels, ot as priorityLabels, p as customFieldSchema, pt as repositorySecretFields, r as attachmentSchema, rt as permissionScope, st as projectSchema, t as ApiError, tt as parsePublicOutput, ut as remoteKinds, v as defaultWorkflow, vt as scopes, w as difficulties, wt as stagedUploadSchema, x as deliverySettingsSchema, xt as signInMethodLabels, y as deletedAccountName, yt as searchResultTypes, zt as brokerSettingsSchema } from "./src-D70GmZ5R.mjs";
3
+ import { and, arrayContains, arrayOverlaps, asc, count, desc, eq, getTableColumns, gt, inArray, isNotNull, isNull, lt, lte, ne, notInArray, or, sql } from "drizzle-orm";
4
4
  import { alias, bigint, boolean, check, customType, foreignKey, index, integer, jsonb, pgTable, primaryKey, text, timestamp, unionAll, unique, uniqueIndex, uuid } from "drizzle-orm/pg-core";
5
5
  import { z } from "zod";
6
6
  import { decodeJwt } from "jose";
7
7
  import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes } from "node:crypto";
8
8
  import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
9
+ import { isDeepStrictEqual } from "node:util";
9
10
  import { icons } from "@iconify-json/lucide";
10
11
  import { fromMarkdown } from "mdast-util-from-markdown";
11
12
  import { Expo } from "expo-server-sdk";
@@ -136,6 +137,7 @@ const projects = pgTable("projects", {
136
137
  key: text("key").notNull().unique(),
137
138
  name: text("name").notNull(),
138
139
  description: text("description").notNull().default(""),
140
+ repositories: text("repositories").array().notNull().default([]),
139
141
  nextNumber: integer("next_number").notNull().default(1),
140
142
  position: bigint("position", { mode: "number" }).notNull().default(0),
141
143
  version: integer("version").notNull().default(1),
@@ -218,6 +220,7 @@ const tickets = pgTable("tickets", {
218
220
  automationReady: boolean("automation_ready").notNull().default(false),
219
221
  claimRequired: boolean("claim_required").notNull().default(false),
220
222
  holdReason: text("hold_reason"),
223
+ intakeCredentialId: text("intake_credential_id"),
221
224
  startAt: timestamp("start_at", { withTimezone: true }),
222
225
  dueAt: timestamp("due_at", { withTimezone: true }),
223
226
  archived: boolean("archived").notNull().default(false),
@@ -233,6 +236,7 @@ const tickets = pgTable("tickets", {
233
236
  index("ticket_parent").on(table.parentId),
234
237
  index("ticket_project_position").on(table.projectId, table.position),
235
238
  index("ticket_state").on(table.stateId),
239
+ index("ticket_intake_credential").on(table.intakeCredentialId).where(sql`${table.intakeCredentialId} IS NOT NULL`),
236
240
  foreignKey({
237
241
  name: "ticket_workflow_state",
238
242
  columns: [
@@ -302,6 +306,7 @@ const receipts = pgTable("mutation_receipts", {
302
306
  operation: text("operation").notNull(),
303
307
  hash: text("hash").notNull(),
304
308
  result: jsonb("result").notNull(),
309
+ projectId: uuid("project_id"),
305
310
  createdAt: time("created_at")
306
311
  }, (table) => [primaryKey({ columns: [table.actorId, table.requestId] })]);
307
312
  const storageBackends = pgTable("storage_backends", {
@@ -367,6 +372,20 @@ const stagedUploads = pgTable("staged_uploads", {
367
372
  size: integer("size").notNull(),
368
373
  createdAt: time("created_at")
369
374
  }, (table) => [index("staged_uploads_created").on(table.createdAt)]);
375
+ const uploadUrls = pgTable("upload_urls", {
376
+ id: uuid("id").primaryKey(),
377
+ projectId: uuid("project_id").notNull().references(() => projects.id, { onDelete: "cascade" }),
378
+ ticketId: uuid("ticket_id").references(() => tickets.id, { onDelete: "cascade" }),
379
+ actorId: text("actor_id").notNull().references(() => user.id, { onDelete: "cascade" }),
380
+ credential: jsonb("credential"),
381
+ name: text("name").notNull(),
382
+ contentType: text("content_type").notNull(),
383
+ size: integer("size").notNull(),
384
+ tokenHash: text("token_hash").notNull().unique(),
385
+ expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
386
+ usedAt: timestamp("used_at", { withTimezone: true }),
387
+ createdAt: time("created_at")
388
+ }, (table) => [index("upload_urls_expires").on(table.expiresAt)]);
370
389
  const bytea = customType({ dataType: () => "bytea" });
371
390
  const attachmentContent = pgTable("attachment_content", {
372
391
  id: uuid("id").primaryKey().references(() => attachments.id, { onDelete: "cascade" }),
@@ -711,8 +730,7 @@ const createObjectStore = (configuration) => {
711
730
  if (!create) throw new Error("Unsupported object storage provider");
712
731
  return create(configuration.config);
713
732
  };
714
- //#endregion
715
- //#region src/blob-storage.ts
733
+ const intakeAttachmentLifetime = 2592e6;
716
734
  const stagedUploadCutoff = () => new Date(Date.now() - stagedUploadLifetime);
717
735
  const digest$1 = (bytes) => createHash("sha256").update(bytes).digest("hex");
718
736
  function decodeBlobData(value, limit) {
@@ -811,8 +829,34 @@ function createBlobStorage(database, instance, factory = createObjectStore, sche
811
829
  });
812
830
  }
813
831
  async function collectBatch() {
832
+ await database.db.transaction(async (tx) => {
833
+ await tx.execute(sql`SELECT pg_advisory_xact_lock(7361080)`);
834
+ const expired = await tx.select({
835
+ attachment: attachments,
836
+ key: tickets.key,
837
+ title: tickets.title
838
+ }).from(attachments).innerJoin(tickets, eq(tickets.id, attachments.ticketId)).where(and(isNotNull(tickets.intakeCredentialId), eq(tickets.holdReason, intakeHoldReason), lt(attachments.createdAt, /* @__PURE__ */ new Date(Date.now() - intakeAttachmentLifetime)))).orderBy(asc(attachments.createdAt), asc(attachments.id)).limit(100);
839
+ if (!expired.length) return;
840
+ await tx.delete(attachments).where(inArray(attachments.id, expired.map(({ attachment }) => attachment.id)));
841
+ await appendEvents(tx, expired.map(({ attachment, key, title }) => ({
842
+ projectId: attachment.projectId,
843
+ ticketId: attachment.ticketId,
844
+ actorId: null,
845
+ type: "attachment.deleted",
846
+ data: {
847
+ attachmentId: attachment.id,
848
+ key,
849
+ title,
850
+ source: {
851
+ kind: "system",
852
+ id: "blob-cleanup"
853
+ }
854
+ }
855
+ })));
856
+ });
814
857
  return database.db.transaction(async (tx) => {
815
858
  await tx.delete(stagedUploads).where(lt(stagedUploads.createdAt, stagedUploadCutoff()));
859
+ await tx.delete(uploadUrls).where(lt(uploadUrls.expiresAt, /* @__PURE__ */ new Date()));
816
860
  const rows = await tx.select({ object: blobObjects }).from(blobObjects).leftJoin(attachments, eq(attachments.objectId, blobObjects.id)).leftJoin(profilePhotos, eq(profilePhotos.objectId, blobObjects.id)).leftJoin(stagedUploads, eq(stagedUploads.id, blobObjects.id)).leftJoin(projects, or(eq(projects.iconObjectId, blobObjects.id), eq(projects.backgroundObjectId, blobObjects.id))).where(and(isNull(attachments.id), isNull(profilePhotos.userId), isNull(stagedUploads.id), isNull(projects.id), lt(blobObjects.createdAt, /* @__PURE__ */ new Date(Date.now() - 864e5)))).limit(20).for("update", {
817
861
  of: blobObjects,
818
862
  skipLocked: true
@@ -1065,14 +1109,23 @@ function parseQuery(query) {
1065
1109
  terms: query
1066
1110
  };
1067
1111
  }
1068
- const querySegments = ({ phrases, terms }) => terms ? [...phrases, terms] : phrases;
1069
- function postgresSearch(query) {
1070
- return and(...querySegments(parseQuery(query)).map((segment) => {
1071
- const value = likePattern(segment);
1072
- return or(ilike(tickets.title, value), ilike(tickets.description, value), ilike(tickets.key, value), ilike(tickets.acceptanceCriteria, value), sql`array_to_string(${tickets.labels}, ' ') ILIKE ${value}`, sql`array_to_string(${tickets.scopes}, ' ') ILIKE ${value}`);
1073
- }));
1074
- }
1075
- const postgresRank = (query) => sql`CASE WHEN ${and(...querySegments(parseQuery(query)).map((segment) => ilike(tickets.title, likePattern(segment))))} THEN 0 ELSE 1 END`;
1112
+ const phrasePattern = (phrase, word) => `(?<!${word})${phrase.replace(/[\\^$.|?*+()[\]{}]/gu, "\\$&").replaceAll(" ", "\\s+")}(?!${word})`;
1113
+ const phraseRegExp = (phrase) => new RegExp(phrasePattern(phrase, "[\\p{L}\\p{N}_]"), "iu");
1114
+ const segmentTests = ({ phrases, terms }) => [...phrases.map((phrase) => {
1115
+ const pattern = phrasePattern(phrase, "[[:alnum:]_]");
1116
+ return (text) => sql`${text} ~* ${pattern}`;
1117
+ }), ...terms ? [(text) => sql`${text} ILIKE ${likePattern(terms)}`] : []];
1118
+ const searchedText = [
1119
+ tickets.title,
1120
+ tickets.description,
1121
+ tickets.key,
1122
+ tickets.acceptanceCriteria,
1123
+ sql`array_to_string(${tickets.labels}, ' ')`,
1124
+ sql`array_to_string(${tickets.scopes}, ' ')`
1125
+ ];
1126
+ const textMatch = (query) => and(...segmentTests(query).map((test) => or(...searchedText.map(test))));
1127
+ const postgresSearch = (query) => textMatch(parseQuery(query));
1128
+ const postgresRank = (query) => sql`CASE WHEN ${and(...segmentTests(parseQuery(query)).map((test) => test(tickets.title)))} THEN 0 ELSE 1 END`;
1076
1129
  const builtinSearch = { where: async (_db, _ids, query) => ({
1077
1130
  where: postgresSearch(query),
1078
1131
  rank: postgresRank(query),
@@ -1138,13 +1191,17 @@ function createTicketSearch(database, instance, factory = createTicketIndex, sch
1138
1191
  if (!state || !clock || state.error || state.configHash !== fingerprint(settings) || state.cursor !== clock.value) return fallback();
1139
1192
  const root = await instance.read();
1140
1193
  try {
1194
+ const parsed = parseQuery(query);
1141
1195
  const hits = await factory({
1142
1196
  provider: "elasticsearch",
1143
1197
  config: settings
1144
- }).find(root.identity, projectIds, parseQuery(query));
1198
+ }).find(root.identity, projectIds, parsed);
1145
1199
  const ids = hits.map((hit) => hit.id), ranks = hits.map((hit) => hit.rank);
1146
1200
  return {
1147
- where: ids.length ? inArray(tickets.id, ids) : sql`false`,
1201
+ where: ids.length ? and(inArray(tickets.id, ids), textMatch({
1202
+ phrases: parsed.phrases,
1203
+ terms: ""
1204
+ })) : sql`false`,
1148
1205
  rank: sql`(${`{${ranks.join(",")}}`}::int[])[array_position(${`{${ids.join(",")}}`}::uuid[], ${tickets.id})]`,
1149
1206
  engine: "index",
1150
1207
  indexing: false
@@ -2387,14 +2444,19 @@ function predicateSQL({ field, operator, values }) {
2387
2444
  const overlap = sql`${tickets.labels} && ${sql.param(values)}::text[]`;
2388
2445
  return operator === "any" ? overlap : sql`NOT (${overlap})`;
2389
2446
  }
2447
+ if (field === "automation") {
2448
+ const ready = sql`(${tickets.automationReady} AND ${tickets.holdReason} IS NULL)`;
2449
+ const match = or(...values.map((value) => value === "ready" ? ready : sql`NOT ${ready}`));
2450
+ return operator === "any" ? match : sql`NOT (${match})`;
2451
+ }
2390
2452
  const column = field === "assigneeId" ? tickets.assigneeId : filterColumns[field];
2391
2453
  const named = field === "assigneeId" ? values.filter((value) => value !== "none") : values, unset = field === "assigneeId" && values.includes("none");
2392
2454
  if (operator === "any") return or(named.length ? inArray(column, named) : void 0, unset ? isNull(column) : void 0);
2393
2455
  return and(named.length ? or(isNull(column), notInArray(column, named)) : void 0, unset ? isNotNull(column) : void 0);
2394
2456
  }
2395
2457
  const predicateWhere = (filter) => and(...ticketPredicates(filter).map(predicateSQL));
2396
- function ticketOrder(input) {
2397
- const direction = input.order ?? defaultOrder[input.sort];
2458
+ function ticketOrder({ sort = "priority", order }) {
2459
+ const direction = order ?? defaultOrder[sort];
2398
2460
  const by = (column) => direction === "asc" ? sql`${column} ASC NULLS LAST` : sql`${column} DESC NULLS LAST`;
2399
2461
  return [{
2400
2462
  priority: by(rank(tickets.priority, priorities)),
@@ -2410,7 +2472,28 @@ function ticketOrder(input) {
2410
2472
  kind: by(tickets.kind),
2411
2473
  assignee: by(sql`(SELECT lower(name) FROM "user" WHERE id = ${tickets.assigneeId})`),
2412
2474
  manual: by(tickets.position)
2413
- }[input.sort], asc(tickets.number)];
2475
+ }[sort], asc(tickets.number)];
2476
+ }
2477
+ const relevanceOrder = (q, relevance) => [
2478
+ sql`CASE WHEN ${ticketMatch(q)} THEN 0 ELSE 1 END`,
2479
+ sql`${relevance} NULLS LAST`,
2480
+ desc(tickets.updatedAt)
2481
+ ];
2482
+ async function listQuery(ctx, projectIds, input) {
2483
+ if (!input.q) return {
2484
+ where: void 0,
2485
+ order: ticketOrder(input)
2486
+ };
2487
+ const where = or(ticketMatch(input.q), postgresSearch(input.q));
2488
+ if (input.sort) return {
2489
+ where,
2490
+ order: ticketOrder(input)
2491
+ };
2492
+ const match = await ctx.search.where(ctx.db, projectIds, input.q);
2493
+ return {
2494
+ where,
2495
+ order: [...relevanceOrder(input.q, match.rank), asc(tickets.number)]
2496
+ };
2414
2497
  }
2415
2498
  async function pageOf(ctx, where, order, input) {
2416
2499
  const rows = await ctx.db.select({
@@ -2453,7 +2536,8 @@ async function createTicketRow(ctx, input) {
2453
2536
  customFields: intakeOnly(ctx.actor) ? {} : applyCustomFieldValues(await projectCustomFields(ctx), null, customFields),
2454
2537
  ...await allocateTicket(ctx),
2455
2538
  id,
2456
- createdBy: ctx.actor.id
2539
+ createdBy: ctx.actor.id,
2540
+ intakeCredentialId: intakeOnly(ctx.actor) ? ctx.actor.ownerId : null
2457
2541
  }).returning());
2458
2542
  await subscribe(ctx, row.projectId, row.id, [ctx.actor.id, row.assigneeId]);
2459
2543
  return {
@@ -2500,12 +2584,16 @@ const hitRow = z.object({
2500
2584
  const summarize = (ticket) => ticketSummarySchema.parse(json(ticket));
2501
2585
  const snippetLength = 240;
2502
2586
  function snippet(body, query) {
2503
- const text = body?.replace(/\s+/gu, " ").trim() ?? "", lower = text.toLowerCase(), parsed = parseQuery(query);
2587
+ const text = body?.replace(/\s+/gu, " ").trim() ?? "", lower = text.toLowerCase(), { phrases, terms } = parseQuery(query);
2504
2588
  let at = -1, size = 0;
2505
- for (const needle of [...querySegments(parsed), ...parsed.terms.split(" ")]) {
2506
- const found = needle ? lower.indexOf(needle.toLowerCase()) : -1;
2507
- if (found >= 0 && (at < 0 || found < at)) [at, size] = [found, needle.length];
2589
+ const closest = (found, length) => {
2590
+ if (found >= 0 && (at < 0 || found < at)) [at, size] = [found, length];
2591
+ };
2592
+ for (const phrase of phrases) {
2593
+ const found = phraseRegExp(phrase).exec(text);
2594
+ if (found) closest(found.index, found[0].length);
2508
2595
  }
2596
+ for (const needle of [terms, ...terms.split(" ")]) if (needle) closest(lower.indexOf(needle.toLowerCase()), needle.length);
2509
2597
  if (at < 0) return "";
2510
2598
  if (text.length <= snippetLength) return text;
2511
2599
  const start = Math.max(0, Math.min(at + Math.floor((size - snippetLength) / 2), text.length - snippetLength)), end = start + snippetLength;
@@ -2637,9 +2725,9 @@ const ticketHandlers = {
2637
2725
  async list_tickets(ctx, input) {
2638
2726
  const parent = input.parentId ? await getTicket(ctx, input.parentId) : void 0;
2639
2727
  const related = input.relatedTo ? await getTicket(ctx, input.relatedTo) : void 0;
2640
- const search = input.q ? await ctx.search.where(ctx.db, [project(ctx).id], input.q) : void 0;
2728
+ const search = await listQuery(ctx, [project(ctx).id], input);
2641
2729
  await assertCustomFieldFilters(ctx, [project(ctx).id], input);
2642
- const page = await pageOf(ctx, and(eq(tickets.projectId, project(ctx).id), parent ? eq(tickets.parentId, parent.id) : void 0, related ? or(inArray(tickets.id, ctx.db.select({ id: relations$1.targetId }).from(relations$1).where(eq(relations$1.sourceId, related.id))), inArray(tickets.id, ctx.db.select({ id: relations$1.sourceId }).from(relations$1).where(eq(relations$1.targetId, related.id)))) : void 0, predicateWhere(input), input.archived === "include" ? void 0 : eq(tickets.archived, input.archived === "only"), dueWhere(input.due), input.stateId ? eq(tickets.stateId, input.stateId) : void 0, input.scheduledFrom ? sql`${scheduleEnd} >= ${input.scheduledFrom}::timestamptz` : void 0, input.scheduledTo ? sql`${scheduleStart} < ${input.scheduledTo}::timestamptz` : void 0, search?.where), ticketOrder(input), input);
2730
+ const page = await pageOf(ctx, and(eq(tickets.projectId, project(ctx).id), parent ? eq(tickets.parentId, parent.id) : void 0, related ? or(inArray(tickets.id, ctx.db.select({ id: relations$1.targetId }).from(relations$1).where(eq(relations$1.sourceId, related.id))), inArray(tickets.id, ctx.db.select({ id: relations$1.sourceId }).from(relations$1).where(eq(relations$1.targetId, related.id)))) : void 0, predicateWhere(input), input.archived === "include" ? void 0 : eq(tickets.archived, input.archived === "only"), dueWhere(input.due), input.stateId ? eq(tickets.stateId, input.stateId) : void 0, input.scheduledFrom ? sql`${scheduleEnd} >= ${input.scheduledFrom}::timestamptz` : void 0, input.scheduledTo ? sql`${scheduleStart} < ${input.scheduledTo}::timestamptz` : void 0, search.where), search.order, input);
2643
2731
  return input.view === "summary" ? {
2644
2732
  ...page,
2645
2733
  items: page.items.map(summarize)
@@ -2652,11 +2740,11 @@ const ticketHandlers = {
2652
2740
  total: 0,
2653
2741
  activity: []
2654
2742
  };
2655
- const ids = allowed.map((row) => row.id);
2656
- const where = and(inArray(tickets.projectId, ids), eq(tickets.assigneeId, ctx.actor.id), eq(tickets.archived, false), openTicket, input.q ? (await ctx.search.where(ctx.db, ids, input.q)).where : void 0);
2743
+ const ids = allowed.map((row) => row.id), search = await listQuery(ctx, ids, input);
2744
+ const where = and(inArray(tickets.projectId, ids), eq(tickets.assigneeId, ctx.actor.id), eq(tickets.archived, false), openTicket, search.where);
2657
2745
  const activity = input.activity ? await ctx.db.select().from(events).where(inArray(events.projectId, ids)).orderBy(desc(events.id)).limit(input.activity) : [];
2658
2746
  return {
2659
- ...await pageOf(ctx, where, ticketOrder(input), input),
2747
+ ...await pageOf(ctx, where, search.order, input),
2660
2748
  activity
2661
2749
  };
2662
2750
  },
@@ -2871,7 +2959,7 @@ const ticketHandlers = {
2871
2959
  const allowed = await accessibleProjectRows(ctx.db, ctx.actor, input.workspaceId, "exclude");
2872
2960
  if (!allowed.length) return { items: [] };
2873
2961
  const ids = allowed.map((row) => row.id), match = await ctx.search.where(ctx.db, ids, input.q);
2874
- return { items: (await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, ids), eq(tickets.archived, false), or(ticketMatch(input.q), match.where))).orderBy(sql`CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END`, sql`${match.rank} NULLS LAST`, desc(tickets.updatedAt)).limit(input.limit)).flatMap((ticket) => {
2962
+ return { items: (await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, ids), eq(tickets.archived, false), or(ticketMatch(input.q), match.where))).orderBy(...relevanceOrder(input.q, match.rank)).limit(input.limit)).flatMap((ticket) => {
2875
2963
  const owner = allowed.find((row) => row.id === ticket.projectId);
2876
2964
  if (!owner) return [];
2877
2965
  const summary = input.view === "summary", body = `${ticket.description} ${ticket.acceptanceCriteria}`;
@@ -2991,7 +3079,7 @@ const ticketHandlers = {
2991
3079
  },
2992
3080
  async claim_ticket(ctx, input) {
2993
3081
  const ticket = await getTicket(ctx, input.ticketId);
2994
- assertVersion(ticket, input.expectedVersion, "ticket", "claiming");
3082
+ if (input.expectedVersion !== void 0) assertVersion(ticket, input.expectedVersion, "ticket", "claiming");
2995
3083
  const eligible = await readiness(ctx, ticket);
2996
3084
  if (!eligible.ready) fail(409, "not_ready", "The ticket is not ready to claim", eligible);
2997
3085
  const claim = one(await ctx.db.insert(claims).values({
@@ -3013,7 +3101,10 @@ const ticketHandlers = {
3013
3101
  workerId: claim.workerId,
3014
3102
  version: row.version
3015
3103
  });
3016
- return claim;
3104
+ return {
3105
+ ...claim,
3106
+ ticket: row
3107
+ };
3017
3108
  },
3018
3109
  async renew_claim(ctx, input) {
3019
3110
  const ticket = await getTicket(ctx, input.ticketId);
@@ -3384,6 +3475,90 @@ async function addComment(ctx, input) {
3384
3475
  });
3385
3476
  return row;
3386
3477
  }
3478
+ async function attachFile(ctx, ticket, id, bytes, file) {
3479
+ if (intakeOnly(ctx.actor)) {
3480
+ const stored = one(await ctx.db.select({
3481
+ files: sql`count(*) filter (where ${attachments.ticketId} = ${ticket.id})`.mapWith(Number),
3482
+ bytes: sql`coalesce(sum(${attachments.size}), 0)`.mapWith(Number)
3483
+ }).from(attachments).innerJoin(tickets, eq(tickets.id, attachments.ticketId)).where(and(eq(tickets.intakeCredentialId, ctx.actor.ownerId), eq(tickets.holdReason, intakeHoldReason))));
3484
+ if (stored.files >= 10) fail(409, "attachment_limit", `An intake credential can attach at most 10 files to a ticket`);
3485
+ if (stored.bytes + bytes.length > 1e9) fail(409, "attachment_limit", "This intake credential's untriaged tickets hold too many attachment bytes");
3486
+ }
3487
+ await ctx.blobs.put(ctx.db, id, bytes, file.contentType);
3488
+ const row = one(await ctx.db.insert(attachments).values({
3489
+ id,
3490
+ objectId: id,
3491
+ projectId: project(ctx).id,
3492
+ ticketId: ticket.id,
3493
+ actorId: ctx.actor.id,
3494
+ name: file.name,
3495
+ contentType: file.contentType,
3496
+ size: bytes.length
3497
+ }).returning());
3498
+ await publish(ctx, "attachment.created", ticket.id, {
3499
+ attachmentId: row.id,
3500
+ size: row.size
3501
+ });
3502
+ return row;
3503
+ }
3504
+ async function stageFile(ctx, id, bytes, file) {
3505
+ await ctx.blobs.put(ctx.db, id, bytes, file.contentType);
3506
+ const row = one(await ctx.db.insert(stagedUploads).values({
3507
+ id,
3508
+ projectId: project(ctx).id,
3509
+ actorId: ctx.actor.id,
3510
+ name: file.name,
3511
+ contentType: file.contentType,
3512
+ size: bytes.length
3513
+ }).returning());
3514
+ return {
3515
+ ...row,
3516
+ expiresAt: new Date(row.createdAt.getTime() + stagedUploadLifetime)
3517
+ };
3518
+ }
3519
+ const uploadToken = /^kvup_[A-Za-z0-9_-]{43}$/;
3520
+ const hashUploadToken = (token) => createHash("sha256").update(token).digest("hex");
3521
+ const uploadCredential = z.object({
3522
+ ownerId: z.string(),
3523
+ scopes: z.array(z.enum(scopes)),
3524
+ projectIds: z.array(z.string()).optional(),
3525
+ apiKey: z.boolean()
3526
+ }).nullable();
3527
+ function assertUploadUrlOpen(row) {
3528
+ if (row.usedAt) fail(410, "upload_url_unavailable", "This upload URL was already used");
3529
+ if (row.expiresAt.getTime() <= Date.now()) fail(410, "upload_url_unavailable", "This upload URL expired; request a new one");
3530
+ }
3531
+ async function receiveUpload(domain, token, contentLength, read) {
3532
+ const { db } = domain.database, row = uploadToken.test(token) ? (await db.select().from(uploadUrls).where(eq(uploadUrls.tokenHash, hashUploadToken(token))))[0] : void 0;
3533
+ if (!row) return fail(404, "not_found", "This upload URL does not exist");
3534
+ assertUploadUrlOpen(row);
3535
+ const wrongSize = (sent) => fail(400, "invalid_attachment", `This upload URL takes exactly ${row.size} bytes with that Content-Length; the request sent ${sent}`);
3536
+ if (contentLength !== String(row.size)) wrongSize(contentLength ?? "no Content-Length");
3537
+ const reserved = /* @__PURE__ */ new Date();
3538
+ const [held] = await db.update(uploadUrls).set({ usedAt: reserved }).where(and(eq(uploadUrls.id, row.id), isNull(uploadUrls.usedAt), gt(uploadUrls.expiresAt, reserved))).returning({ id: uploadUrls.id });
3539
+ if (!held) fail(410, "upload_url_unavailable", "This upload URL is already receiving an upload");
3540
+ try {
3541
+ const bytes = await read(row.size);
3542
+ if (bytes.length !== row.size) wrongSize(bytes.length > row.size ? "more" : String(bytes.length));
3543
+ const credential = uploadCredential.parse(row.credential);
3544
+ if (credential?.apiKey) {
3545
+ const key = (await db.select().from(apikey).where(eq(apikey.id, credential.ownerId)))[0];
3546
+ if (!key?.enabled || key.expiresAt && key.expiresAt <= /* @__PURE__ */ new Date()) fail(401, "unauthorized", "The API credential that requested this upload URL was revoked");
3547
+ }
3548
+ const actor = await domain.actor(row.actorId, credential ? {
3549
+ ownerId: credential.ownerId,
3550
+ scopes: credential.scopes,
3551
+ ...credential.projectIds ? { projectIds: credential.projectIds } : {}
3552
+ } : void 0);
3553
+ return await domain.transact(async (tx, as) => {
3554
+ const ctx = await as(actor, row.projectId);
3555
+ return row.ticketId ? attachmentSchema.parse(json(await attachFile(ctx, await getTicket(ctx, row.ticketId), row.id, bytes, row))) : stagedUploadSchema.parse(json(await stageFile(ctx, row.id, bytes, row)));
3556
+ });
3557
+ } catch (error) {
3558
+ await db.update(uploadUrls).set({ usedAt: null }).where(and(eq(uploadUrls.id, row.id), eq(uploadUrls.usedAt, reserved)));
3559
+ throw error;
3560
+ }
3561
+ }
3387
3562
  const collaborationHandlers = {
3388
3563
  async list_relations(ctx, input) {
3389
3564
  const ticket = await getTicket(ctx, input.ticketId);
@@ -3493,41 +3668,34 @@ const collaborationHandlers = {
3493
3668
  };
3494
3669
  },
3495
3670
  async upload_attachment(ctx, input) {
3496
- const ticket = await getTicket(ctx, input.ticketId);
3497
- const bytes = decodeBlobData(input.data, attachmentLimit);
3498
- const id = crypto.randomUUID();
3499
- await ctx.blobs.put(ctx.db, id, bytes, input.contentType);
3500
- const row = one(await ctx.db.insert(attachments).values({
3501
- id,
3502
- objectId: id,
3503
- projectId: project(ctx).id,
3504
- ticketId: ticket.id,
3505
- actorId: ctx.actor.id,
3506
- name: input.name,
3507
- contentType: input.contentType,
3508
- size: bytes.length
3509
- }).returning());
3510
- await publish(ctx, "attachment.created", ticket.id, {
3511
- attachmentId: row.id,
3512
- size: row.size
3513
- });
3514
- return row;
3671
+ return attachFile(ctx, await getTicket(ctx, input.ticketId), crypto.randomUUID(), decodeBlobData(input.data, attachmentLimit), input);
3515
3672
  },
3516
3673
  async stage_upload(ctx, input) {
3517
- const bytes = decodeBlobData(input.data, attachmentLimit);
3518
- const id = crypto.randomUUID();
3519
- await ctx.blobs.put(ctx.db, id, bytes, input.contentType);
3520
- const row = one(await ctx.db.insert(stagedUploads).values({
3521
- id,
3674
+ return stageFile(ctx, crypto.randomUUID(), decodeBlobData(input.data, attachmentLimit), input);
3675
+ },
3676
+ async create_upload_url(ctx, input) {
3677
+ const ticket = input.ticketId ? await getTicket(ctx, input.ticketId) : void 0, token = `kvup_${randomBytes(32).toString("base64url")}`, { id, ownerId, scopes: granted, projectIds, machine } = ctx.actor, apiKey = machine && (await ctx.db.select({ id: apikey.id }).from(apikey).where(eq(apikey.id, ownerId))).length > 0;
3678
+ const row = one(await ctx.db.insert(uploadUrls).values({
3679
+ id: crypto.randomUUID(),
3522
3680
  projectId: project(ctx).id,
3523
- actorId: ctx.actor.id,
3681
+ ticketId: ticket?.id ?? null,
3682
+ actorId: id,
3683
+ credential: machine ? {
3684
+ ownerId,
3685
+ scopes: granted,
3686
+ projectIds,
3687
+ apiKey
3688
+ } : null,
3524
3689
  name: input.name,
3525
3690
  contentType: input.contentType,
3526
- size: bytes.length
3691
+ size: input.size,
3692
+ tokenHash: hashUploadToken(token),
3693
+ expiresAt: new Date(Date.now() + uploadUrlLifetime)
3527
3694
  }).returning());
3528
3695
  return {
3529
- ...row,
3530
- expiresAt: new Date(row.createdAt.getTime() + stagedUploadLifetime)
3696
+ uploadId: row.id,
3697
+ url: new URL(`/api/uploads/${token}`, ctx.publicUrl).href,
3698
+ expiresAt: row.expiresAt
3531
3699
  };
3532
3700
  },
3533
3701
  async discard_upload(ctx, input) {
@@ -5291,6 +5459,11 @@ async function takePrefix(ctx, prefix, projectId) {
5291
5459
  projectId: owner
5292
5460
  }).onConflictDoNothing();
5293
5461
  }
5462
+ async function assertRepositoriesFree(ctx, projectId, workspaceId, repositories) {
5463
+ if (!repositories.length) return;
5464
+ const [holder] = await ctx.db.select({ id: projects.id }).from(projects).where(and(eq(projects.workspaceId, workspaceId), isNull(projects.deletedAt), isNull(projects.archivedAt), ne(projects.id, projectId), arrayOverlaps(projects.repositories, repositories))).limit(1);
5465
+ if (holder) fail(409, "duplicate_repository", "Another project in this workspace links this repository");
5466
+ }
5294
5467
  async function storeImage(ctx, image) {
5295
5468
  const objectId = crypto.randomUUID();
5296
5469
  await ctx.blobs.put(ctx.db, objectId, decodeImage(image), image.contentType);
@@ -5406,18 +5579,20 @@ const projectHandlers = {
5406
5579
  const current = project(ctx);
5407
5580
  assertVersion(current, input.expectedVersion, "project");
5408
5581
  const key = input.key ?? current.key;
5409
- const sharing = input.publicReadEnabled !== void 0 && input.publicReadEnabled !== current.publicReadEnabled, details = key !== current.key || input.name !== current.name || input.description !== current.description || !!input.visibility && input.visibility !== current.visibility;
5582
+ const sharing = input.publicReadEnabled !== void 0 && input.publicReadEnabled !== current.publicReadEnabled, details = key !== current.key || input.name !== current.name || input.description !== current.description || !!input.visibility && input.visibility !== current.visibility || !!input.repositories && !isDeepStrictEqual(input.repositories, current.repositories);
5410
5583
  if (sharing) requirePermission(ctx.permissions, "sharing.manage", "project");
5411
5584
  if (details || !sharing) requirePermission(ctx.permissions, "project.manage", "project");
5412
5585
  if (key !== current.key) {
5413
5586
  await (await takePrefix(ctx, key, current.id))(current.id);
5414
5587
  await ctx.db.update(tickets).set({ key: sql`${key}::text || '-' || ${tickets.number}` }).where(eq(tickets.projectId, current.id));
5415
5588
  }
5589
+ if (input.repositories && !current.archivedAt) await assertRepositoriesFree(ctx, current.id, current.workspaceId, input.repositories);
5416
5590
  const row = one(await ctx.db.update(projects).set({
5417
5591
  key,
5418
5592
  name: input.name,
5419
5593
  ...input.visibility ? { visibility: input.visibility } : {},
5420
5594
  ...input.publicReadEnabled === void 0 ? {} : { publicReadEnabled: input.publicReadEnabled },
5595
+ ...input.repositories ? { repositories: input.repositories } : {},
5421
5596
  description: input.description,
5422
5597
  version: current.version + 1,
5423
5598
  updatedAt: /* @__PURE__ */ new Date()
@@ -5835,6 +6010,7 @@ async function assertTransferable(ctx, workspaceId) {
5835
6010
  const current = project(ctx);
5836
6011
  if (workspaceId === current.workspaceId) fail(400, "validation_error", "The project is already in this workspace");
5837
6012
  await requireWorkspace(ctx, current.workspaceId, "project.manage");
6013
+ if (!current.archivedAt) await assertRepositoriesFree(ctx, current.id, workspaceId, current.repositories);
5838
6014
  }
5839
6015
  async function projectPeople(ctx, workspaceId = project(ctx).workspaceId) {
5840
6016
  const current = project(ctx), qualifiedUser = sql`${user}."id"`;
@@ -5997,6 +6173,7 @@ const lifecycleHandlers = {
5997
6173
  assertVersion(current, input.expectedVersion, "project", input.archived ? "archiving" : "restoring");
5998
6174
  if (!!current.archivedAt === input.archived) return current;
5999
6175
  if (input.archived) assertNoBlockers(await claimBlockers(ctx));
6176
+ else await assertRepositoriesFree(ctx, current.id, current.workspaceId, current.repositories);
6000
6177
  const row = await changed(ctx, { archivedAt: input.archived ? /* @__PURE__ */ new Date() : null });
6001
6178
  await publish(ctx, input.archived ? "project.archived" : "project.restored", null, { version: row.version });
6002
6179
  return row;
@@ -6969,7 +7146,12 @@ const complete = (status) => closedStates.some((closed) => closed === status);
6969
7146
  const intakeHoldReason = "Untrusted intake: human triage required";
6970
7147
  const intakeQuarantined = (ticket) => ticket.holdReason === intakeHoldReason;
6971
7148
  const intakeOnly = (actor) => actor.scopes.length > 0 && actor.scopes.every((scope) => scope === "intake");
6972
- const hasScope = (actor, scope, operation) => intakeOnly(actor) ? operation === "create_ticket" : actor.scopes.includes("admin") || actor.scopes.includes(scope) || scope === "read" && actor.scopes.some((value) => value !== "intake");
7149
+ const intakeOperations = /* @__PURE__ */ new Set([
7150
+ "create_ticket",
7151
+ "upload_attachment",
7152
+ "list_attachments"
7153
+ ]);
7154
+ const hasScope = (actor, scope, operation) => intakeOnly(actor) ? !!operation && intakeOperations.has(operation) : actor.scopes.includes("admin") || actor.scopes.includes(scope) || scope === "read" && actor.scopes.some((value) => value !== "intake");
6973
7155
  function requireScope(actor, scope, operation) {
6974
7156
  if (!hasScope(actor, scope, operation)) fail(403, "insufficient_scope", `This credential requires ${scope} permission`);
6975
7157
  }
@@ -7018,6 +7200,16 @@ const publicActor = {
7018
7200
  projectIds: []
7019
7201
  };
7020
7202
  const projectMatch = (identifier) => or(sql`${projects.id}::text = ${identifier}`, eq(projects.key, identifier.toUpperCase()), sql`${projects.id} IN (SELECT pp.project_id FROM ${projectPrefixes} pp WHERE pp.prefix = ${identifier.toUpperCase()})`);
7203
+ const namesRemote = (identifier) => /[/@]/.test(identifier);
7204
+ async function projectByRemote(db, actor, identifier) {
7205
+ const remote = normalizeRemote(identifier);
7206
+ const linked = remote ? await db.select().from(projects).where(and(isNull(projects.deletedAt), arrayContains(projects.repositories, [remote]))) : [];
7207
+ const reachable = [];
7208
+ for (const row of linked) if ((!actor.projectIds || actor.projectIds.includes(row.id)) && await projectAccess(db, actor, row)) reachable.push(row);
7209
+ const live = reachable.filter((row) => !row.archivedAt), named = live.length ? live : reachable;
7210
+ if (named.length > 1) fail(409, "ambiguous_project", "More than one of your projects links this repository", { projects: named.map((row) => row.key) });
7211
+ return one(named);
7212
+ }
7021
7213
  const issuedKey = /^([A-Z][A-Z0-9]{1,9})-([1-9]\d{0,8})$/;
7022
7214
  function ticketMatch(identifier) {
7023
7215
  const key = identifier.toUpperCase(), [, prefix, number] = issuedKey.exec(key) ?? [];
@@ -7033,7 +7225,7 @@ async function context(db, actor, scope, projectId, workspaceId, permission = sc
7033
7225
  ctx.permissions = access.permissions;
7034
7226
  }
7035
7227
  if (!projectId) return ctx;
7036
- const row = one(await db.select().from(projects).where(and(isNull(projects.deletedAt), projectMatch(projectId))).limit(1));
7228
+ const row = namesRemote(projectId) ? await projectByRemote(db, actor, projectId) : one(await db.select().from(projects).where(and(isNull(projects.deletedAt), projectMatch(projectId))).limit(1));
7037
7229
  if (actor.projectIds && !actor.projectIds.includes(row.id)) fail(404, "not_found", "Project was not found");
7038
7230
  const access = await projectAccess(db, actor, row);
7039
7231
  if (!access) fail(404, "not_found", "Project was not found");
@@ -7046,7 +7238,9 @@ async function context(db, actor, scope, projectId, workspaceId, permission = sc
7046
7238
  };
7047
7239
  }
7048
7240
  async function getTicket(ctx, identifier) {
7049
- return one(await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, project(ctx).id), ticketMatch(identifier))).limit(1));
7241
+ const ticket = one(await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, project(ctx).id), ticketMatch(identifier))).limit(1));
7242
+ if (intakeOnly(ctx.actor) && !(ticket.intakeCredentialId === ctx.actor.ownerId && intakeQuarantined(ticket))) fail(404, "not_found", "The requested record was not found");
7243
+ return ticket;
7050
7244
  }
7051
7245
  const insertBatch = 1e3;
7052
7246
  async function insertAll(rows, insert) {
@@ -7338,10 +7532,13 @@ function createDomain(database, services = {}) {
7338
7532
  const receipt = requestId ? (await tx.select().from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1))[0] : void 0;
7339
7533
  if (receipt && (receipt.hash !== hash || receipt.operation !== name)) fail(409, "idempotency_conflict", "This requestId was already used for another write");
7340
7534
  if (receipt && (name === "delete_project" || name === "delete_workspace")) return parseOutput(name, receipt.result);
7341
- const ctx = await context(tx, actor, spec.scope, projectId, workspaceId, permission, name);
7535
+ const ctx = await context(tx, actor, spec.scope, receipt?.projectId ?? projectId, workspaceId, permission, name);
7342
7536
  if (receipt) {
7343
7537
  if ((name === "create_time_entry" || name === "update_time_entry" || name === "delete_time_entry") && "ticketId" in input) await getTicket(ctx, input.ticketId);
7344
- const prior = parseOutput(name, receipt.result);
7538
+ const prior = parseOutput(name, name === "claim_ticket" && "ticketId" in input && !operations.claim_ticket.output.safeParse(receipt.result).success ? json({
7539
+ ...claimSchema.parse(receipt.result),
7540
+ ticket: await getTicket(ctx, input.ticketId)
7541
+ }) : receipt.result);
7345
7542
  if (name === "create_project") await context(tx, actor, "read", projectSchema.parse(prior).id);
7346
7543
  if (name === "create_workspace") await requireWorkspace(ctx, workspaceSchema.parse(prior).id, null);
7347
7544
  if (name === "relocate_ticket") await context(tx, actor, "write", ticketSchema.parse(prior).projectId);
@@ -7354,7 +7551,8 @@ function createDomain(database, services = {}) {
7354
7551
  requestId,
7355
7552
  hash,
7356
7553
  operation: name,
7357
- result
7554
+ result,
7555
+ projectId: ctx.project?.id ?? null
7358
7556
  });
7359
7557
  return result;
7360
7558
  });
@@ -7379,7 +7577,7 @@ function createDomain(database, services = {}) {
7379
7577
  }
7380
7578
  async function readEvents(identity, cursor, projectId) {
7381
7579
  if (!hasScope(identity, "read")) fail(403, "insufficient_scope", "Read permission is required");
7382
- const allowed = await accessibleProjects(identity), named = projectId ? await database.db.select({ id: projects.id }).from(projects).where(projectMatch(projectId)) : [];
7580
+ const allowed = await accessibleProjects(identity), named = !projectId ? [] : namesRemote(projectId) ? [await projectByRemote(database.db, identity, projectId)] : await database.db.select({ id: projects.id }).from(projects).where(projectMatch(projectId));
7383
7581
  const ids = allowed.filter((row) => !projectId || named.some((match) => match.id === row.id)).map((row) => row.id);
7384
7582
  if (projectId && !ids.length) fail(404, "not_found", "Project was not found");
7385
7583
  const clock = one(await database.db.select().from(eventClock).where(eq(eventClock.id, 1)));
@@ -8060,4 +8258,4 @@ function createAuth(database, config) {
8060
8258
  };
8061
8259
  }
8062
8260
  //#endregion
8063
- export { stagedUploads as $, requireSession as A, attachmentContent as B, defaultSenders as C, createAccess as D, createHttpSender as E, decodeImage as F, events as G, blobObjects as H, imageResponse as I, notificationPreferences as J, instanceSettings as K, stagedUploadCutoff as L, createTicketSearch as M, createTicketIndex as N, createWriteLimiter as O, createBlobStorage as P, pushDevices as Q, createObjectStore as R, receiveRepositoryWebhook as S, DeliveryError as T, deliveryCheckpoints as U, attachments as V, eventClock as W, profilePhotos as X, notifications as Y, projects as Z, one as _, deliverySecrets as a, matchesOIDCBinding as at, requireRow as b, ownerBlocker as c, createDomain as d, storageBackends as et, errorEnvelope as f, notify as g, json as h, createInstance as i, workspaces as it, readCalendarFeed as j, requireInstanceAdmin as k, appendEvents as l, intakeOnly as m, emailCodesAvailable as n, tickets as nt, otherActiveAdministrator as o, fail as p, members as q, signInProviders as r, workspaceMembers as rt, ownedWorkspaces as s, createAuth as t, subscriptions as tt, assertVersion as u, reachable as v, deliverBatch as w, processImports as x, recipientColumns as y, createPollingJob as z };
8261
+ export { pushDevices as $, requireInstanceAdmin as A, createPollingJob as B, defaultSenders as C, receiveUpload as D, createHttpSender as E, createBlobStorage as F, eventClock as G, attachments as H, decodeImage as I, members as J, events as K, imageResponse as L, readCalendarFeed as M, createTicketSearch as N, createAccess as O, createTicketIndex as P, projects as Q, stagedUploadCutoff as R, receiveRepositoryWebhook as S, DeliveryError as T, blobObjects as U, attachmentContent as V, deliveryCheckpoints as W, notifications as X, notificationPreferences as Y, profilePhotos as Z, one as _, deliverySecrets as a, workspaces as at, requireRow as b, ownerBlocker as c, createDomain as d, stagedUploads as et, errorEnvelope as f, notify as g, json as h, createInstance as i, workspaceMembers as it, requireSession as j, createWriteLimiter as k, appendEvents as l, intakeOnly as m, emailCodesAvailable as n, subscriptions as nt, otherActiveAdministrator as o, matchesOIDCBinding as ot, fail as p, instanceSettings as q, signInProviders as r, tickets as rt, ownedWorkspaces as s, createAuth as t, storageBackends as tt, assertVersion as u, reachable as v, deliverBatch as w, processImports as x, recipientColumns as y, createObjectStore as z };
@@ -1,4 +1,4 @@
1
- import { C as errorSchema, Ct as streamErrorSchema, E as eventSchema, G as notificationReadInput, H as notificationIdsInput, K as notificationsSchema, Q as parsePublicOutput, U as notificationPreferencesInput, W as notificationPreferencesSchema, Y as operations, Z as parseOutput, a as checkpointSchema, at as publicOperations, q as okSchema, t as ApiError, yt as snapshotSchema } from "./src-D80LNZKP.mjs";
1
+ import { Dt as streamErrorSchema, G as notificationIdsInput, J as notificationReadInput, K as notificationPreferencesInput, O as eventSchema, Q as operations, St as snapshotSchema, T as errorSchema, X as okSchema, Y as notificationsSchema, ct as publicOperations, et as parseOutput, o as checkpointSchema, q as notificationPreferencesSchema, t as ApiError, tt as parsePublicOutput } from "./src-D70GmZ5R.mjs";
2
2
  import { z } from "zod";
3
3
  import { parseArgs } from "node:util";
4
4
  import { constants } from "node:fs";
@@ -653,7 +653,7 @@ profile list | profile remove <name> --yes
653
653
  --input <json|-> Operation input as a JSON object; - reads stdin
654
654
  --title <text> Ticket title
655
655
  --body <markdown> Comment body
656
- --expected-version <n> Required version for update, delete, acquire and rescheduling recovery
656
+ --expected-version <n> Required version for update, delete and rescheduling recovery; optional for acquire
657
657
  --changes <json> Ticket update changes as a JSON object
658
658
  --request-id <uuid> Reuse for a deliberate retry across invocations
659
659
  --json Compact JSON output (default: indented JSON)
@@ -1,4 +1,4 @@
1
- import { Bt as searchSettingsSchema, Pt as brokerSettingsSchema, Rt as s3SettingsSchema, m as defaultClaimMaxLeaseSeconds, o as claimLeaseSecondsSchema } from "./src-D80LNZKP.mjs";
1
+ import { Gt as searchSettingsSchema, Ut as s3SettingsSchema, g as defaultClaimMaxLeaseSeconds, s as claimLeaseSecondsSchema, zt as brokerSettingsSchema } from "./src-D70GmZ5R.mjs";
2
2
  import pg from "pg";
3
3
  import { drizzle } from "drizzle-orm/node-postgres";
4
4
  import { relations } from "drizzle-orm";