@maudecode/kanvil 0.4.4 → 0.5.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 (110) hide show
  1. package/apps/server/.env.example +5 -0
  2. package/apps/server/dist/{auth-B-Tz8SF4.mjs → auth-Cs_whzgk.mjs} +300 -119
  3. package/apps/server/dist/client.mjs +1 -1
  4. package/apps/server/dist/{config-CRADzIvL.mjs → config-DKuNQY5b.mjs} +15 -7
  5. package/apps/server/dist/index.mjs +98 -30
  6. package/apps/server/dist/migrate.mjs +2 -2
  7. package/apps/server/dist/recover.mjs +4 -4
  8. package/apps/server/dist/{src-BCtZ3wbK.mjs → src-DptOUNRd.mjs} +53 -18
  9. package/apps/web/dist/assets/{CompositeItem-DvEBmVYM.js → CompositeItem-CXqewtEL.js} +1 -1
  10. package/apps/web/dist/assets/{Trans-NwgJNiLl.js → Trans-B1rRlWca.js} +1 -1
  11. package/apps/web/dist/assets/{admin-settings-BVWOe67Z.js → admin-settings-DE3MfBFE.js} +1 -1
  12. package/apps/web/dist/assets/{archive-restore-j20QLQCl.js → archive-restore-76sStk8K.js} +1 -1
  13. package/apps/web/dist/assets/az-AZ-bFxpMMZN.js +1 -0
  14. package/apps/web/dist/assets/{az-Dj0LhQ5O.js → az-B7OgHoaE.js} +1 -1
  15. package/apps/web/dist/assets/{corner-left-up-BAGDaT_1.js → corner-left-up-PD-73Nae.js} +1 -1
  16. package/apps/web/dist/assets/{de-BWVFKRmU.js → de-B-bv38Q0.js} +1 -1
  17. package/apps/web/dist/assets/de-DE-BeXI30Ll.js +1 -0
  18. package/apps/web/dist/assets/default-bQFGTwIp.js +56 -0
  19. package/apps/web/dist/assets/{delivery-settings-B26dRk_O.js → delivery-settings-BvMeOsCS.js} +1 -1
  20. package/apps/web/dist/assets/{el-IYSYQmsa.js → el-BQHQHQgX.js} +1 -1
  21. package/apps/web/dist/assets/el-GR-SgRHlWhY.js +1 -0
  22. package/apps/web/dist/assets/{ellipsis-Cx1SIn8a.js → ellipsis-GF1HwTDb.js} +1 -1
  23. package/apps/web/dist/assets/es-ES-BF1J5vap.js +1 -0
  24. package/apps/web/dist/assets/{es-C3DJP0aS.js → es-f5Xr8ldr.js} +1 -1
  25. package/apps/web/dist/assets/{forms-BwtRqvGj.js → forms-BlEL8C_P.js} +1 -1
  26. package/apps/web/dist/assets/{fr-BlVUPa15.js → fr-DSR0htnl.js} +1 -1
  27. package/apps/web/dist/assets/fr-FR-D_sldwf6.js +1 -0
  28. package/apps/web/dist/assets/{hi-CINzKB6O.js → hi-BaAJEWuW.js} +1 -1
  29. package/apps/web/dist/assets/hi-IN-CfOw4TWf.js +1 -0
  30. package/apps/web/dist/assets/{highlight-XHbOOx7A.js → highlight-DXT2xgt8.js} +1 -1
  31. package/apps/web/dist/assets/id-ID-CLKtHO7Q.js +1 -0
  32. package/apps/web/dist/assets/{id-CpyQwNyO.js → id-kxogWcip.js} +1 -1
  33. package/apps/web/dist/assets/{index-MTE_naHG.js → index-CYVrs22O.js} +5 -5
  34. package/apps/web/dist/assets/{invitations-Ch02KEAB.js → invitations-BPik3J_G.js} +1 -1
  35. package/apps/web/dist/assets/{it-CxMkGyiF.js → it-C_qk40U6.js} +1 -1
  36. package/apps/web/dist/assets/it-IT-eVZNW5qX.js +1 -0
  37. package/apps/web/dist/assets/{item-CRS9Ai9p.js → item-TCHuZNK6.js} +1 -1
  38. package/apps/web/dist/assets/{ja-D72d7qkv.js → ja-Dn5h64V9.js} +1 -1
  39. package/apps/web/dist/assets/ja-JP-D96njn-P.js +1 -0
  40. package/apps/web/dist/assets/{ko-BHuNjRQ3.js → ko-CjaoEPuG.js} +1 -1
  41. package/apps/web/dist/assets/ko-KR-Bash_ieA.js +1 -0
  42. package/apps/web/dist/assets/{label-settings-tE57CalJ.js → label-settings-DZTorNDm.js} +1 -1
  43. package/apps/web/dist/assets/{markdown-blocks-CN-hJdJS.js → markdown-blocks-BQLO-H_7.js} +1 -1
  44. package/apps/web/dist/assets/{member-directory-CoLB-2Oc.js → member-directory-Dx_jQYfI.js} +1 -1
  45. package/apps/web/dist/assets/mk-MK-BWQWGIRx.js +1 -0
  46. package/apps/web/dist/assets/{mk-D3TPH_Mz.js → mk-jLeGD50T.js} +1 -1
  47. package/apps/web/dist/assets/{nl-BHJbk-Wr.js → nl-AVtC5ygT.js} +1 -1
  48. package/apps/web/dist/assets/nl-NL-D1tvo0Gd.js +1 -0
  49. package/apps/web/dist/assets/{pl-JW_P_Gb9.js → pl-Dup1h_Y1.js} +1 -1
  50. package/apps/web/dist/assets/pl-PL-DNgGUEcQ.js +1 -0
  51. package/apps/web/dist/assets/{project-lifecycle-DuV33mlL.js → project-lifecycle-n1ZZbYfu.js} +1 -1
  52. package/apps/web/dist/assets/{project-settings-9yJbfZRu.js → project-settings-B_Ih5kiz.js} +1 -1
  53. package/apps/web/dist/assets/pt-BR-CZsUZQ6P.js +1 -0
  54. package/apps/web/dist/assets/{pt-BR-DWL_sEp3.js → pt-BR-Cm04EWM-.js} +1 -1
  55. package/apps/web/dist/assets/{public-project-M93Zpx_e.js → public-project-BivDjU_a.js} +1 -1
  56. package/apps/web/dist/assets/{rotate-ccw-DcHaluuA.js → rotate-ccw-CSaKhZYt.js} +1 -1
  57. package/apps/web/dist/assets/{ru-DQNby7hW.js → ru-IeycXsyE.js} +1 -1
  58. package/apps/web/dist/assets/ru-RU-4ew92YE_.js +1 -0
  59. package/apps/web/dist/assets/{search-DQtwggUH.js → search-Ble6WSEU.js} +3 -3
  60. package/apps/web/dist/assets/{send-gpGpxsCa.js → send-BLMZ8gQ0.js} +1 -1
  61. package/apps/web/dist/assets/settings-CH48qQK2.js +1 -0
  62. package/apps/web/dist/assets/{sliders-horizontal-BxgR4pFt.js → sliders-horizontal-Db6kRvuw.js} +1 -1
  63. package/apps/web/dist/assets/{switch-UPVGS5kv.js → switch-BeX-0Fwq.js} +1 -1
  64. package/apps/web/dist/assets/{table-D8iAxhJH.js → table-UJp5S9yy.js} +1 -1
  65. package/apps/web/dist/assets/ticket-detail-C24opfNW.js +2 -0
  66. package/apps/web/dist/assets/{ticket-editor-B5eOWIj4.js → ticket-editor--d_bG9C1.js} +2 -2
  67. package/apps/web/dist/assets/{ticket-list-C965rmmf.js → ticket-list-DRbZWYHf.js} +1 -1
  68. package/apps/web/dist/assets/{ticket-list-controls-ua3m0uRB.js → ticket-list-controls-fYBottFC.js} +1 -1
  69. package/apps/web/dist/assets/{tr-BNZEkwqd.js → tr-DtT_dKUK.js} +1 -1
  70. package/apps/web/dist/assets/tr-TR-CM381XCw.js +1 -0
  71. package/apps/web/dist/assets/{uk-Dmlqvk63.js → uk-CsbeHSS6.js} +1 -1
  72. package/apps/web/dist/assets/uk-UA-AeOiWCVo.js +1 -0
  73. package/apps/web/dist/assets/{upload-Cu0J8ZFJ.js → upload-DUEg04Nj.js} +1 -1
  74. package/apps/web/dist/assets/{useQueries-BEzeS0hU.js → useQueries-5lW2C5Sb.js} +1 -1
  75. package/apps/web/dist/assets/vi-VN-td08xXv9.js +1 -0
  76. package/apps/web/dist/assets/{vi-KALEc3bk.js → vi-rkf1BOLx.js} +1 -1
  77. package/apps/web/dist/assets/{visual-editor-DyhI5fa5.js → visual-editor-DkFLjouD.js} +1 -1
  78. package/apps/web/dist/assets/{workspace-settings-DGr-Lwnz.js → workspace-settings-Bcsp-VoJ.js} +1 -1
  79. package/apps/web/dist/assets/zh-CN-D3dhEYLS.js +1 -0
  80. package/apps/web/dist/assets/{zh-CN-DfruhRl9.js → zh-CN-Xo-QId5w.js} +1 -1
  81. package/apps/web/dist/assets/zh-TW-CfhOEFo8.js +1 -0
  82. package/apps/web/dist/assets/{zh-TW-C4keUYMa.js → zh-TW-DEnTqjbn.js} +1 -1
  83. package/apps/web/dist/index.html +7 -7
  84. package/docs/api.md +16 -6
  85. package/docs/releasing.md +16 -2
  86. package/docs/self-hosting.md +8 -0
  87. package/package.json +1 -1
  88. package/apps/web/dist/assets/az-AZ-DlsPlUZi.js +0 -1
  89. package/apps/web/dist/assets/de-DE-DLCaqUUe.js +0 -1
  90. package/apps/web/dist/assets/default-D6I-Xfqm.js +0 -56
  91. package/apps/web/dist/assets/el-GR-P2XcwrSU.js +0 -1
  92. package/apps/web/dist/assets/es-ES-CJoX8f3w.js +0 -1
  93. package/apps/web/dist/assets/fr-FR-yLkQdy6a.js +0 -1
  94. package/apps/web/dist/assets/hi-IN-y8dqej7z.js +0 -1
  95. package/apps/web/dist/assets/id-ID-DSvh8mGH.js +0 -1
  96. package/apps/web/dist/assets/it-IT-BUcfUqNo.js +0 -1
  97. package/apps/web/dist/assets/ja-JP-C-jLP_Ay.js +0 -1
  98. package/apps/web/dist/assets/ko-KR-C5Ozdm2E.js +0 -1
  99. package/apps/web/dist/assets/mk-MK-BdM_SsE8.js +0 -1
  100. package/apps/web/dist/assets/nl-NL-CwjB5sva.js +0 -1
  101. package/apps/web/dist/assets/pl-PL-6-PMhTBd.js +0 -1
  102. package/apps/web/dist/assets/pt-BR-TD_wt6Hr.js +0 -1
  103. package/apps/web/dist/assets/ru-RU-Bk1a6Oln.js +0 -1
  104. package/apps/web/dist/assets/settings-Co61IE04.js +0 -1
  105. package/apps/web/dist/assets/ticket-detail-BKilKtg_.js +0 -2
  106. package/apps/web/dist/assets/tr-TR-CUfBb7K9.js +0 -1
  107. package/apps/web/dist/assets/uk-UA-BDaXBsDP.js +0 -1
  108. package/apps/web/dist/assets/vi-VN-Bgb-L6mE.js +0 -1
  109. package/apps/web/dist/assets/zh-CN-BypkvBe6.js +0 -1
  110. package/apps/web/dist/assets/zh-TW-CgMsT_XS.js +0 -1
@@ -1,5 +1,5 @@
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-CRADzIvL.mjs";
2
- import { $ as permissionLabels, A as hasAdminRole, At as workspaceSchema, D as filterKey, Dt as ticketPredicates, E as eventSchema, Et as ticketImportSchema, F as isHexColor, J as operationPermission, Lt as s3SettingsSchema, Mt as authenticationPolicySchema, N as importSecretFields, Nt as brokerSettingsSchema, O as filterList, Ot as ticketSchema, P as invitationStatuses, Pt as instanceUpdateSchema, Q as parsePublicOutput, Rt as searchAuthentication, 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, k as formatEvent, kt as workflowOrder, 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 searchSettingsSchema } from "./src-BCtZ3wbK.mjs";
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-DKuNQY5b.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-DptOUNRd.mjs";
3
3
  import { and, asc, count, desc, eq, getTableColumns, gt, ilike, 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";
@@ -15,6 +15,7 @@ import { request as request$1 } from "node:https";
15
15
  import { BlockList, isIP } from "node:net";
16
16
  import nodemailer from "nodemailer";
17
17
  import { APIError, createAuthMiddleware } from "better-auth/api";
18
+ import { AsyncLocalStorage } from "node:async_hooks";
18
19
  import { betterAuth } from "better-auth";
19
20
  import { admin, genericOAuth, jwt } from "better-auth/plugins";
20
21
  import { emailOTP } from "better-auth/plugins/email-otp";
@@ -769,7 +770,7 @@ function createBlobStorage(database, instance, factory = createObjectStore, sche
769
770
  skipLocked: true
770
771
  });
771
772
  for (const row of rows) {
772
- const backend = await instance.backend(requireRow(row.backendId ?? void 0)), key = requireRow(row.objectKey ?? void 0), bytes = requireRow(row.data ?? void 0);
773
+ const backend = await instance.backend(requireRow(row.backendId ?? void 0), tx), key = requireRow(row.objectKey ?? void 0), bytes = requireRow(row.data ?? void 0);
773
774
  await transfer(backend.spec, async (store) => {
774
775
  await store.put(key, bytes, row.contentType);
775
776
  const verified = await store.get(key, row.size);
@@ -818,7 +819,7 @@ function createBlobStorage(database, instance, factory = createObjectStore, sche
818
819
  });
819
820
  for (const { object } of rows) {
820
821
  if (object.backendId && object.objectKey) {
821
- const backend = await instance.backend(object.backendId), key = object.objectKey;
822
+ const backend = await instance.backend(object.backendId, tx), key = object.objectKey;
822
823
  await transfer(backend.spec, (store) => store.remove(key));
823
824
  }
824
825
  await tx.delete(blobObjects).where(eq(blobObjects.id, object.id));
@@ -872,6 +873,14 @@ function imageResponse(blob) {
872
873
  }
873
874
  //#endregion
874
875
  //#region src/adapters/elasticsearch.ts
876
+ const fields$1 = [
877
+ "title^3",
878
+ "key^3",
879
+ "description",
880
+ "acceptanceCriteria",
881
+ "labels",
882
+ "scopes"
883
+ ];
875
884
  function fail$1(_status, _code, message) {
876
885
  throw new Error(message);
877
886
  }
@@ -970,37 +979,44 @@ function createElasticsearchIndex(configuration) {
970
979
  let scrollId;
971
980
  try {
972
981
  const found = [];
982
+ let score;
973
983
  let response = await accepted(await call$1(settings, `${settings.index}/_search?scroll=1m`, {
974
984
  method: "POST",
975
985
  body: JSON.stringify({
976
986
  size: 1e3,
977
987
  _source: false,
978
- sort: ["_doc"],
979
988
  query: { bool: {
980
989
  filter: [{ terms: { projectId: [...projectIds] } }, { term: { instanceId: identity } }],
981
- must: [{ multi_match: {
982
- query,
983
- fields: [
984
- "title^3",
985
- "key^3",
986
- "description",
987
- "acceptanceCriteria",
988
- "labels",
989
- "scopes"
990
- ],
990
+ must: [...query.terms ? [{ multi_match: {
991
+ query: query.terms,
992
+ fields: fields$1,
991
993
  operator: "and",
992
994
  fuzziness: "AUTO"
993
- } }]
995
+ } }] : [], ...query.phrases.map((phrase) => ({ multi_match: {
996
+ query: phrase,
997
+ fields: fields$1,
998
+ type: "phrase"
999
+ } }))]
994
1000
  } }
995
1001
  })
996
1002
  }));
997
1003
  for (;;) {
998
1004
  const result = z.object({
999
1005
  _scroll_id: z.string().optional(),
1000
- hits: z.object({ hits: z.array(z.object({ _id: z.uuid() })) })
1006
+ hits: z.object({ hits: z.array(z.object({
1007
+ _id: z.uuid(),
1008
+ _score: z.number()
1009
+ })) })
1001
1010
  }).parse(await response.json());
1002
1011
  scrollId = result["_scroll_id"] ?? scrollId;
1003
- found.push(...result.hits.hits.map((hit) => hit["_id"]));
1012
+ for (const hit of result.hits.hits) {
1013
+ const rank = (found.at(-1)?.rank ?? 0) + (score === hit["_score"] ? 0 : 1);
1014
+ score = hit["_score"];
1015
+ found.push({
1016
+ id: hit["_id"],
1017
+ rank
1018
+ });
1019
+ }
1004
1020
  if (found.length > 1e4) throw new Error("External search unavailable");
1005
1021
  if (!result.hits.hits.length || !scrollId) break;
1006
1022
  response = await accepted(await call$1(settings, "_search/scroll", {
@@ -1034,12 +1050,32 @@ const createTicketIndex = (configuration) => {
1034
1050
  //#endregion
1035
1051
  //#region src/search.ts
1036
1052
  const likePattern = (query) => `%${query.replaceAll("\\", "\\\\").replaceAll("%", "\\%").replaceAll("_", "\\_")}%`;
1053
+ function parseQuery(query) {
1054
+ const phrases = [];
1055
+ const terms = query.replace(/"([^"]*)"/gu, (_, phrase) => {
1056
+ const value = phrase.replace(/\s+/gu, " ").trim();
1057
+ if (value && phrases.length < 8 && !phrases.some((seen) => seen.toLowerCase() === value.toLowerCase())) phrases.push(value);
1058
+ return " ";
1059
+ }).replace(/\s+/gu, " ").trim();
1060
+ return phrases.length || terms ? {
1061
+ phrases,
1062
+ terms
1063
+ } : {
1064
+ phrases,
1065
+ terms: query
1066
+ };
1067
+ }
1068
+ const querySegments = ({ phrases, terms }) => terms ? [...phrases, terms] : phrases;
1037
1069
  function postgresSearch(query) {
1038
- const value = likePattern(query);
1039
- 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}`);
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
+ }));
1040
1074
  }
1075
+ const postgresRank = (query) => sql`CASE WHEN ${and(...querySegments(parseQuery(query)).map((segment) => ilike(tickets.title, likePattern(segment))))} THEN 0 ELSE 1 END`;
1041
1076
  const builtinSearch = { where: async (_db, _ids, query) => ({
1042
1077
  where: postgresSearch(query),
1078
+ rank: postgresRank(query),
1043
1079
  engine: "postgres",
1044
1080
  indexing: false
1045
1081
  }) };
@@ -1093,6 +1129,7 @@ function createTicketSearch(database, instance, factory = createTicketIndex, sch
1093
1129
  const settings = await instance.search();
1094
1130
  const fallback = () => ({
1095
1131
  where: postgresSearch(query),
1132
+ rank: postgresRank(query),
1096
1133
  engine: "postgres",
1097
1134
  indexing: !!settings
1098
1135
  });
@@ -1101,12 +1138,14 @@ function createTicketSearch(database, instance, factory = createTicketIndex, sch
1101
1138
  if (!state || !clock || state.error || state.configHash !== fingerprint(settings) || state.cursor !== clock.value) return fallback();
1102
1139
  const root = await instance.read();
1103
1140
  try {
1104
- const ids = await factory({
1141
+ const hits = await factory({
1105
1142
  provider: "elasticsearch",
1106
1143
  config: settings
1107
- }).find(root.identity, projectIds, query);
1144
+ }).find(root.identity, projectIds, parseQuery(query));
1145
+ const ids = hits.map((hit) => hit.id), ranks = hits.map((hit) => hit.rank);
1108
1146
  return {
1109
1147
  where: ids.length ? inArray(tickets.id, ids) : sql`false`,
1148
+ rank: sql`(${`{${ranks.join(",")}}`}::int[])[array_position(${`{${ids.join(",")}}`}::uuid[], ${tickets.id})]`,
1110
1149
  engine: "index",
1111
1150
  indexing: false
1112
1151
  };
@@ -2254,23 +2293,17 @@ async function validateReferences(ctx, ticketId, changes) {
2254
2293
  }
2255
2294
  async function assertNoCycle(ctx, sourceId, targetId) {
2256
2295
  if (sourceId === targetId) fail(409, "dependency_cycle", "A ticket cannot depend on itself");
2257
- const all = await ctx.db.select({
2258
- id: tickets.id,
2259
- parentId: tickets.parentId
2260
- }).from(tickets).where(eq(tickets.projectId, project(ctx).id));
2261
- const links = await ctx.db.select().from(relations$1).where(and(eq(relations$1.projectId, project(ctx).id), eq(relations$1.type, "blocks")));
2262
- const edges = /* @__PURE__ */ new Map();
2263
- const add = (from, to) => edges.set(from, [...edges.get(from) ?? [], to]);
2264
- for (const row of all) if (row.parentId) add(row.id, row.parentId);
2265
- for (const row of links) add(row.sourceId, row.targetId);
2266
- const pending = [targetId], visited = /* @__PURE__ */ new Set();
2267
- while (pending.length) {
2268
- const node = pending.pop();
2269
- if (!node || visited.has(node)) continue;
2270
- if (node === sourceId) fail(409, "dependency_cycle", "This relationship would create a dependency cycle");
2271
- visited.add(node);
2272
- pending.push(...edges.get(node) ?? []);
2273
- }
2296
+ const { rows } = await ctx.db.execute(sql`WITH RECURSIVE reach(id) AS (
2297
+ SELECT ${targetId}::uuid
2298
+ UNION
2299
+ SELECT edge.next FROM reach JOIN (
2300
+ SELECT source_id AS id, target_id AS next FROM ${relations$1} WHERE type = 'blocks'
2301
+ UNION ALL
2302
+ SELECT id, parent_id FROM ${tickets} WHERE parent_id IS NOT NULL
2303
+ ) edge ON edge.id = reach.id
2304
+ )
2305
+ SELECT 1 FROM reach WHERE id = ${sourceId}::uuid LIMIT 1`);
2306
+ if (rows.length) fail(409, "dependency_cycle", "This relationship would create a dependency cycle");
2274
2307
  }
2275
2308
  async function updateTicketRow(ctx, input) {
2276
2309
  const current = await getTicket(ctx, input.ticketId);
@@ -2417,7 +2450,7 @@ async function createTicketRow(ctx, input) {
2417
2450
  ...fields,
2418
2451
  ...state,
2419
2452
  ...dates,
2420
- customFields: applyCustomFieldValues(await projectCustomFields(ctx), null, customFields),
2453
+ customFields: intakeOnly(ctx.actor) ? {} : applyCustomFieldValues(await projectCustomFields(ctx), null, customFields),
2421
2454
  ...await allocateTicket(ctx),
2422
2455
  id,
2423
2456
  createdBy: ctx.actor.id
@@ -2464,6 +2497,20 @@ const hitRow = z.object({
2464
2497
  title: z.string().nullable(),
2465
2498
  status: z.enum(states).nullable()
2466
2499
  });
2500
+ const summarize = (ticket) => ticketSummarySchema.parse(json(ticket));
2501
+ const snippetLength = 240;
2502
+ function snippet(body, query) {
2503
+ const text = body?.replace(/\s+/gu, " ").trim() ?? "", lower = text.toLowerCase(), parsed = parseQuery(query);
2504
+ 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];
2508
+ }
2509
+ if (at < 0) return "";
2510
+ if (text.length <= snippetLength) return text;
2511
+ const start = Math.max(0, Math.min(at + Math.floor((size - snippetLength) / 2), text.length - snippetLength)), end = start + snippetLength;
2512
+ return `${start > 0 ? "…" : ""}${text.slice(start > 0 ? start + 1 : start, end < text.length ? end - 1 : end)}${end < text.length ? "…" : ""}`;
2513
+ }
2467
2514
  function excerpt(body, query, radius = 80) {
2468
2515
  const text = body?.replace(/\s+/gu, " ").trim() ?? "", at = text.toLowerCase().indexOf(query.toLowerCase());
2469
2516
  if (at < 0) return "";
@@ -2481,25 +2528,29 @@ async function deleteTicketRows(ctx, selection) {
2481
2528
  const ids = [...selected.keys()];
2482
2529
  const [claimed] = await ctx.db.select({ ticketId: claims.ticketId }).from(claims).where(inArray(claims.ticketId, ids)).limit(1);
2483
2530
  if (claimed) fail(409, "claim_conflict", "Release ownership before deleting this ticket", { ticketId: claimed.ticketId });
2484
- await publishMany(ctx, [...(await ctx.db.update(tickets).set({
2485
- parentId: null,
2486
- version: sql`${tickets.version} + 1`,
2487
- updatedAt: /* @__PURE__ */ new Date()
2488
- }).where(and(inArray(tickets.parentId, ids), notInArray(tickets.id, ids))).returning()).map((child) => ({
2489
- type: "ticket.updated",
2490
- ticketId: child.id,
2491
- data: {
2492
- version: child.version,
2493
- changedFields: ["parentId"]
2494
- }
2495
- })), ...[...selected.values()].map((ticket) => ({
2496
- type: "ticket.deleted",
2497
- ticketId: ticket.id,
2498
- data: {
2499
- key: ticket.key,
2500
- version: ticket.version
2501
- }
2502
- }))]);
2531
+ await publishMany(ctx, [
2532
+ ...(await ctx.db.update(tickets).set({
2533
+ parentId: null,
2534
+ version: sql`${tickets.version} + 1`,
2535
+ updatedAt: /* @__PURE__ */ new Date()
2536
+ }).where(and(inArray(tickets.parentId, ids), notInArray(tickets.id, ids))).returning()).map((child) => ({
2537
+ type: "ticket.updated",
2538
+ ticketId: child.id,
2539
+ data: {
2540
+ version: child.version,
2541
+ changedFields: ["parentId"]
2542
+ }
2543
+ })),
2544
+ ...[...selected.values()].map((ticket) => ({
2545
+ type: "ticket.deleted",
2546
+ ticketId: ticket.id,
2547
+ data: {
2548
+ key: ticket.key,
2549
+ version: ticket.version
2550
+ }
2551
+ })),
2552
+ ...await severedRelationEvents(ctx, ids)
2553
+ ]);
2503
2554
  await ctx.db.delete(tickets).where(inArray(tickets.id, ids));
2504
2555
  }
2505
2556
  async function relocation(ctx, ticket, destinationProjectId) {
@@ -2568,6 +2619,16 @@ async function relocation(ctx, ticket, destinationProjectId) {
2568
2619
  fields
2569
2620
  };
2570
2621
  }
2622
+ async function readableTicket(ctx, identifier) {
2623
+ const allowed = await accessibleProjectRows(ctx.db, ctx.actor);
2624
+ const [ticket] = allowed.length ? await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, allowed.map((row) => row.id)), ticketMatch(identifier))).limit(1) : [];
2625
+ const owner = allowed.find((row) => row.id === ticket?.projectId);
2626
+ if (!ticket || !owner) fail(404, "not_found", "Ticket was not found");
2627
+ return {
2628
+ ticket,
2629
+ project: owner
2630
+ };
2631
+ }
2571
2632
  const ticketHandlers = {
2572
2633
  async list_ticket_labels(ctx, input) {
2573
2634
  const labels = labelUsage(ctx, input.archived).as("usage");
@@ -2578,7 +2639,11 @@ const ticketHandlers = {
2578
2639
  const related = input.relatedTo ? await getTicket(ctx, input.relatedTo) : void 0;
2579
2640
  const search = input.q ? await ctx.search.where(ctx.db, [project(ctx).id], input.q) : void 0;
2580
2641
  await assertCustomFieldFilters(ctx, [project(ctx).id], input);
2581
- return 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);
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);
2643
+ return input.view === "summary" ? {
2644
+ ...page,
2645
+ items: page.items.map(summarize)
2646
+ } : page;
2582
2647
  },
2583
2648
  async list_my_work(ctx, input) {
2584
2649
  const allowed = await accessibleProjectRows(ctx.db, ctx.actor, input.workspaceId, "exclude");
@@ -2607,6 +2672,21 @@ const ticketHandlers = {
2607
2672
  };
2608
2673
  },
2609
2674
  async create_ticket(ctx, input) {
2675
+ if (intakeOnly(ctx.actor)) input = {
2676
+ ...input,
2677
+ automationReady: false,
2678
+ holdReason: intakeHoldReason,
2679
+ status: "todo",
2680
+ stateId: void 0,
2681
+ assigneeId: null,
2682
+ parentId: null,
2683
+ archived: false,
2684
+ dueAt: null,
2685
+ startAt: null,
2686
+ customFields: {},
2687
+ uploads: [],
2688
+ labels: operations.create_ticket.input.shape.labels.parse([.../* @__PURE__ */ new Set([...input.labels, "intake"])])
2689
+ };
2610
2690
  const { row, event } = await createTicketRow(ctx, input);
2611
2691
  await publishMany(ctx, [event, ...(await claimStagedUploads(ctx, row.id, input.uploads)).map((item) => ({
2612
2692
  type: "attachment.created",
@@ -2652,7 +2732,7 @@ const ticketHandlers = {
2652
2732
  ...event.data,
2653
2733
  duplicateOf: source.id
2654
2734
  }
2655
- }, ...relationEvents("relation.added", relation)]);
2735
+ }, ...relationEvents("relation.added", relation, [row, source])]);
2656
2736
  return row;
2657
2737
  },
2658
2738
  update_ticket: updateTicket,
@@ -2742,10 +2822,7 @@ const ticketHandlers = {
2742
2822
  return row;
2743
2823
  },
2744
2824
  async resolve_ticket(ctx, input) {
2745
- const allowed = await accessibleProjectRows(ctx.db, ctx.actor);
2746
- const [ticket] = allowed.length ? await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, allowed.map((row) => row.id)), ticketMatch(input.ticketId))).limit(1) : [];
2747
- const owner = allowed.find((row) => row.id === ticket?.projectId);
2748
- if (!ticket || !owner) fail(404, "not_found", "Ticket was not found");
2825
+ const { ticket, project: owner } = await readableTicket(ctx, input.ticketId);
2749
2826
  return {
2750
2827
  ticket,
2751
2828
  project: {
@@ -2793,17 +2870,20 @@ const ticketHandlers = {
2793
2870
  async search_tickets(ctx, input) {
2794
2871
  const allowed = await accessibleProjectRows(ctx.db, ctx.actor, input.workspaceId, "exclude");
2795
2872
  if (!allowed.length) return { items: [] };
2796
- const ids = allowed.map((row) => row.id);
2797
- return { items: (await ctx.db.select().from(tickets).where(and(inArray(tickets.projectId, ids), eq(tickets.archived, false), or(ticketMatch(input.q), (await ctx.search.where(ctx.db, ids, input.q)).where))).orderBy(sql`CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END`, desc(tickets.updatedAt)).limit(input.limit)).flatMap((ticket) => {
2873
+ 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) => {
2798
2875
  const owner = allowed.find((row) => row.id === ticket.projectId);
2799
- return owner ? [{
2800
- ticket,
2876
+ if (!owner) return [];
2877
+ const summary = input.view === "summary", body = `${ticket.description} ${ticket.acceptanceCriteria}`;
2878
+ return [{
2879
+ ticket: summary ? summarize(ticket) : ticket,
2801
2880
  project: {
2802
2881
  id: owner.id,
2803
2882
  key: owner.key,
2804
2883
  name: owner.name
2805
- }
2806
- }] : [];
2884
+ },
2885
+ ...summary && { snippet: snippet(body, input.q) }
2886
+ }];
2807
2887
  }) };
2808
2888
  },
2809
2889
  async search_workspace(ctx, input) {
@@ -2828,23 +2908,26 @@ const ticketHandlers = {
2828
2908
  filtered ? void 0 : sql`SELECT 'project' AS type, projects.id::text AS id, projects.id AS project_id,
2829
2909
  NULL::uuid AS ticket_id, NULL::text AS actor_id, projects.description AS body,
2830
2910
  NULL::text AS event_type, NULL::jsonb AS data, projects.updated_at AS at,
2831
- CASE WHEN projects.key = ${key} THEN 0 ELSE 1 END AS rank, 0 AS family
2911
+ CASE WHEN projects.key = ${key} THEN 0 ELSE 1 END AS rank, 0 AS family,
2912
+ NULL::int AS score
2832
2913
  FROM projects WHERE ${inArray(projects.id, ids)}
2833
2914
  AND (projects.name ILIKE ${like} OR projects.key ILIKE ${like} OR projects.description ILIKE ${like})`,
2834
2915
  sql`SELECT 'ticket' AS type, tickets.id::text AS id, tickets.project_id,
2835
2916
  tickets.id AS ticket_id, NULL::text AS actor_id,
2836
2917
  concat_ws(' ', tickets.description, tickets.acceptance_criteria) AS body,
2837
2918
  NULL::text AS event_type, NULL::jsonb AS data, tickets.updated_at AS at,
2838
- CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END AS rank, 1 AS family
2919
+ CASE WHEN ${ticketMatch(input.q)} THEN 0 ELSE 1 END AS rank, 1 AS family,
2920
+ ${match.rank}::int AS score
2839
2921
  FROM tickets WHERE ${and(ticketScope, or(ticketMatch(input.q), match.where))}`,
2840
2922
  sql`SELECT 'comment' AS type, comments.id::text AS id, comments.project_id,
2841
2923
  comments.ticket_id, comments.actor_id, comments.body, NULL::text AS event_type,
2842
- NULL::jsonb AS data, comments.created_at AS at, 1 AS rank, 2 AS family
2924
+ NULL::jsonb AS data, comments.created_at AS at, 1 AS rank, 2 AS family,
2925
+ NULL::int AS score
2843
2926
  FROM comments JOIN tickets ON tickets.id = comments.ticket_id
2844
2927
  WHERE ${and(ticketScope, sql`comments.body ILIKE ${like}`)}`,
2845
2928
  sql`SELECT 'activity' AS type, events.id::text AS id, events.project_id,
2846
2929
  events.ticket_id, events.actor_id, NULL::text AS body, events.type AS event_type,
2847
- events.data, events.created_at AS at, 1 AS rank, 3 AS family
2930
+ events.data, events.created_at AS at, 1 AS rank, 3 AS family, NULL::int AS score
2848
2931
  FROM events
2849
2932
  LEFT JOIN tickets ON tickets.id = events.ticket_id AND tickets.project_id = events.project_id
2850
2933
  LEFT JOIN "user" ON "user".id = events.actor_id
@@ -2861,14 +2944,15 @@ const ticketHandlers = {
2861
2944
  FROM (${hits}) AS hits
2862
2945
  LEFT JOIN tickets ON tickets.id = hits.ticket_id AND tickets.project_id = hits.project_id
2863
2946
  WHERE ${input.type ? sql`hits.type = ${input.type}` : sql`true`}
2864
- ORDER BY hits.rank, hits.family, hits.at DESC, hits.id DESC
2947
+ ORDER BY hits.rank, hits.family, hits.score NULLS LAST, hits.at DESC, hits.id DESC
2865
2948
  LIMIT ${input.limit} OFFSET ${input.offset}`)).rows.map((row) => hitRow.parse(row));
2866
2949
  const projectsById = new Map(allowed.map((row) => [row.id, row]));
2950
+ const ticketIds = rows.flatMap((row) => row.ticket_id ? [row.ticket_id] : []), summaries = input.view === "summary" && ticketIds.length ? new Map((await ctx.db.select().from(tickets).where(inArray(tickets.id, ticketIds))).map((row) => [row.id, summarize(row)])) : void 0;
2867
2951
  return {
2868
2952
  items: rows.flatMap((row) => {
2869
2953
  const owner = projectsById.get(row.project_id);
2870
2954
  if (!owner) return [];
2871
- const ticket = row.ticket_id && row.key && row.title && row.status ? {
2955
+ const ticket = row.ticket_id && row.key && row.title && row.status ? summaries?.get(row.ticket_id) ?? {
2872
2956
  id: row.ticket_id,
2873
2957
  key: row.key,
2874
2958
  title: row.title,
@@ -2894,6 +2978,7 @@ const ticketHandlers = {
2894
2978
  createdAt: at
2895
2979
  }, true) : ticket?.title ?? "",
2896
2980
  excerpt: excerpt(row.body, input.q),
2981
+ ...input.view === "summary" && { snippet: snippet(row.body, input.q) },
2897
2982
  actorId: row.actor_id,
2898
2983
  at
2899
2984
  }];
@@ -3259,6 +3344,13 @@ function mentionedUserIds(markdown) {
3259
3344
  //#region src/collaboration-operations.ts
3260
3345
  const sourceTicket = alias(tickets, "source_ticket");
3261
3346
  const targetTicket = alias(tickets, "target_ticket");
3347
+ const sourceProject = alias(projects, "source_project");
3348
+ const targetProject = alias(projects, "target_project");
3349
+ async function assertMayConstrain(ctx, relation, sides) {
3350
+ const constrained = relation.type === "blocks" ? relation.targetId : relation.type === "duplicate" ? relation.sourceId : null;
3351
+ const side = sides.find((item) => item.id === constrained);
3352
+ if (side && side.projectId !== project(ctx).id) await context(ctx.db, ctx.actor, "write", side.projectId);
3353
+ }
3262
3354
  async function editableComment(ctx, commentId, expectedVersion) {
3263
3355
  const row = one(await ctx.db.select().from(comments).where(and(eq(comments.projectId, project(ctx).id), eq(comments.id, commentId))));
3264
3356
  if (row.actorId !== ctx.actor.id && !can(ctx, "project.manage")) fail(403, "forbidden", "Only the author or a project administrator can edit this comment");
@@ -3289,18 +3381,45 @@ async function addComment(ctx, input) {
3289
3381
  const collaborationHandlers = {
3290
3382
  async list_relations(ctx, input) {
3291
3383
  const ticket = await getTicket(ctx, input.ticketId);
3292
- return ctx.db.select({
3384
+ const rows = await ctx.db.select({
3293
3385
  ...getTableColumns(relations$1),
3294
3386
  sourceKey: sourceTicket.key,
3295
3387
  sourceTitle: sourceTicket.title,
3388
+ sourceProjectId: sourceTicket.projectId,
3389
+ sourceProjectKey: sourceProject.key,
3296
3390
  targetKey: targetTicket.key,
3297
- targetTitle: targetTicket.title
3298
- }).from(relations$1).innerJoin(sourceTicket, eq(sourceTicket.id, relations$1.sourceId)).innerJoin(targetTicket, eq(targetTicket.id, relations$1.targetId)).where(and(eq(relations$1.projectId, project(ctx).id), or(eq(relations$1.sourceId, ticket.id), eq(relations$1.targetId, ticket.id))));
3391
+ targetTitle: targetTicket.title,
3392
+ targetProjectId: targetTicket.projectId,
3393
+ targetProjectKey: targetProject.key
3394
+ }).from(relations$1).innerJoin(sourceTicket, eq(sourceTicket.id, relations$1.sourceId)).innerJoin(targetTicket, eq(targetTicket.id, relations$1.targetId)).innerJoin(sourceProject, eq(sourceProject.id, sourceTicket.projectId)).innerJoin(targetProject, eq(targetProject.id, targetTicket.projectId)).where(or(eq(relations$1.sourceId, ticket.id), eq(relations$1.targetId, ticket.id)));
3395
+ const here = project(ctx).id, elsewhere = rows.some((row) => row.sourceProjectId !== here || row.targetProjectId !== here), readable = /* @__PURE__ */ new Set([here, ...(elsewhere ? await accessibleProjectRows(ctx.db, ctx.actor) : []).map((row) => row.id)]);
3396
+ return rows.map(({ sourceTitle, sourceProjectId, sourceProjectKey, targetTitle, targetProjectId, targetProjectKey, ...row }) => {
3397
+ const source = readable.has(sourceProjectId), target = readable.has(targetProjectId);
3398
+ return {
3399
+ ...row,
3400
+ ...source && {
3401
+ sourceTitle,
3402
+ sourceProjectId,
3403
+ sourceProjectKey
3404
+ },
3405
+ ...target && {
3406
+ targetTitle,
3407
+ targetProjectId,
3408
+ targetProjectKey
3409
+ },
3410
+ restricted: !source || !target
3411
+ };
3412
+ });
3299
3413
  },
3300
3414
  async add_relation(ctx, input) {
3301
- const current = await getTicket(ctx, input.ticketId), other = await getTicket(ctx, input.targetId);
3415
+ const current = await getTicket(ctx, input.ticketId), { ticket: other } = await readableTicket(ctx, input.targetId);
3302
3416
  const [source, target] = input.direction === "incoming" ? [other, current] : [current, other];
3303
3417
  if (source.id === target.id) fail(409, "invalid_relation", "Choose a different ticket");
3418
+ await assertMayConstrain(ctx, {
3419
+ type: input.type,
3420
+ sourceId: source.id,
3421
+ targetId: target.id
3422
+ }, [current, other]);
3304
3423
  if (input.type === "blocks") await assertNoCycle(ctx, source.id, target.id);
3305
3424
  const [sourceId, targetId] = input.type === "related" ? [source.id, target.id].toSorted() : [source.id, target.id];
3306
3425
  const row = one(await ctx.db.insert(relations$1).values({
@@ -3316,12 +3435,19 @@ const collaborationHandlers = {
3316
3435
  ],
3317
3436
  set: { type: input.type }
3318
3437
  }).returning());
3319
- await publishMany(ctx, relationEvents("relation.added", row));
3438
+ await publishMany(ctx, relationEvents("relation.added", row, [current, other]));
3320
3439
  return row;
3321
3440
  },
3322
3441
  async remove_relation(ctx, input) {
3323
- const row = one(await ctx.db.delete(relations$1).where(and(eq(relations$1.projectId, project(ctx).id), eq(relations$1.id, input.relationId))).returning());
3324
- await publishMany(ctx, relationEvents("relation.removed", row));
3442
+ const found = one(await ctx.db.select().from(relations$1).where(eq(relations$1.id, input.relationId)));
3443
+ const sides = await ctx.db.select({
3444
+ id: tickets.id,
3445
+ projectId: tickets.projectId
3446
+ }).from(tickets).where(inArray(tickets.id, [found.sourceId, found.targetId]));
3447
+ if (!sides.some((side) => side.projectId === project(ctx).id)) fail(404, "not_found", "The requested record was not found");
3448
+ await assertMayConstrain(ctx, found, sides);
3449
+ const row = one(await ctx.db.delete(relations$1).where(eq(relations$1.id, found.id)).returning());
3450
+ await publishMany(ctx, relationEvents("relation.removed", row, sides));
3325
3451
  return { ok: true };
3326
3452
  },
3327
3453
  async list_comments(ctx, input) {
@@ -3974,7 +4100,7 @@ function matches(rule, names, labels) {
3974
4100
  const has = (id) => carried.has(names.get(id)?.toLowerCase() ?? "\0");
3975
4101
  return rule.match === "all" ? rule.labels.every(has) : rule.labels.some(has);
3976
4102
  }
3977
- const qualifies = (rules, names, link, ticket) => link.origin === "outbound" ? matches(rules.outbound, names, ticket.labels) : matches(rules.inbound, names, link.remote?.labels ?? []);
4103
+ const qualifies = (rules, names, link, ticket) => !intakeQuarantined(ticket) && (link?.origin === "inbound" ? matches(rules.inbound, names, link.remote?.labels ?? []) : matches(rules.outbound, names, ticket.labels));
3978
4104
  const workspaceOf = async (db, projectId) => one(await db.select({ workspaceId: projects.workspaceId }).from(projects).where(eq(projects.id, projectId))).workspaceId;
3979
4105
  async function setLinks(db, projectId, links, changes) {
3980
4106
  if (!links.length) return [];
@@ -4036,7 +4162,7 @@ async function queueIssueSync(db, rows) {
4036
4162
  for (const ticket of found) for (const connection of connections) {
4037
4163
  const link = links.find((item) => item.connectionId === connection.id && item.ticketId === ticket.id), labelled = names.get(connection.id) ?? /* @__PURE__ */ new Map();
4038
4164
  if (!link) {
4039
- if (connection.projectId === ticket.projectId && !ticket.archived && !complete(ticket.status) && matches(connection.outbound, labelled, ticket.labels)) await queueSync(db, await db.insert(issueLinks).values({
4165
+ if (connection.projectId === ticket.projectId && !ticket.archived && !complete(ticket.status) && qualifies(connection, labelled, null, ticket)) await queueSync(db, await db.insert(issueLinks).values({
4040
4166
  connectionId: connection.id,
4041
4167
  ticketId: ticket.id,
4042
4168
  origin: "outbound",
@@ -4057,8 +4183,9 @@ async function queueIssueSync(db, rows) {
4057
4183
  state: "review",
4058
4184
  reason: "labels"
4059
4185
  });
4060
- else if (link.state === "active" && !connection.paused) {
4186
+ else if ((link.state === "active" || link.state === "creating") && qualified && !connection.paused) {
4061
4187
  await queueSync(db, [link]);
4188
+ if (link.state !== "active") continue;
4062
4189
  const fresh = commentEvents.filter((event) => event.ticketId === ticket.id && !copied.has(`${link.id}:${String(event.data.commentId)}`));
4063
4190
  if (fresh.length) await db.insert(deliveries).values(fresh.map((event) => ({
4064
4191
  connectionId: connection.id,
@@ -4311,6 +4438,10 @@ async function sendIssueSync(database, senders, secrets, drivers, job, event) {
4311
4438
  }).from(issueLinks).innerJoin(tickets, eq(tickets.id, issueLinks.ticketId)).innerJoin(repositoryConnections, eq(repositoryConnections.id, issueLinks.connectionId)).innerJoin(projects, eq(projects.id, repositoryConnections.projectId)).where(eq(issueLinks.id, job.issueLinkId ?? "")))[0];
4312
4439
  if (!loaded) return finish(database.db, job, "link_removed");
4313
4440
  const { link, ticket, connection } = loaded;
4441
+ if (intakeQuarantined(ticket)) return locked(database, async (db) => {
4442
+ const current = (await db.select({ holdReason: tickets.holdReason }).from(tickets).where(eq(tickets.id, ticket.id)))[0];
4443
+ if (!current || intakeQuarantined(current)) await finish(db, job, current ? "quarantined" : "link_removed");
4444
+ });
4314
4445
  const skip = connection.paused ? "paused" : !loaded.writer ? "access_revoked" : loaded.archivedAt ? "project_archived" : ticket.projectId !== connection.projectId ? "moved" : null;
4315
4446
  if (skip === "access_revoked" || skip === "project_archived") return locked(database, async (db) => {
4316
4447
  await finish(db, job, skip);
@@ -4525,7 +4656,7 @@ async function plan(ctx, connection, { inbound, outbound }) {
4525
4656
  if (link.state === "active") (qualified ? matching : pause).push(link);
4526
4657
  else if (link.state === "paused" && link.reason === "labels" && qualified) review.push(link);
4527
4658
  }
4528
- const exported = rules.outbound.enabled ? (await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, connection.projectId), eq(tickets.archived, false), notInArray(tickets.status, [...closedStates]), notInArray(tickets.id, ctx.db.select({ id: issueLinks.ticketId }).from(issueLinks).where(eq(issueLinks.connectionId, connection.id)))))).filter((ticket) => matches(rules.outbound, names, ticket.labels)) : [];
4659
+ const exported = rules.outbound.enabled ? (await ctx.db.select().from(tickets).where(and(eq(tickets.projectId, connection.projectId), eq(tickets.archived, false), notInArray(tickets.status, [...closedStates]), notInArray(tickets.id, ctx.db.select({ id: issueLinks.ticketId }).from(issueLinks).where(eq(issueLinks.connectionId, connection.id)))))).filter((ticket) => qualifies(rules, names, null, ticket)) : [];
4529
4660
  return {
4530
4661
  matching,
4531
4662
  pause,
@@ -5769,22 +5900,26 @@ async function removeProject(ctx) {
5769
5900
  key: tickets.key,
5770
5901
  version: tickets.version
5771
5902
  }).from(tickets).where(eq(tickets.projectId, current.id)).orderBy(asc(tickets.number));
5772
- await recordAudience(ctx, requireRow((await publishMany(ctx, [...removed.map((ticket) => ({
5773
- type: "ticket.deleted",
5774
- ticketId: ticket.id,
5775
- data: {
5776
- key: ticket.key,
5777
- version: ticket.version
5778
- }
5779
- })), {
5780
- type: "project.deleted",
5781
- ticketId: null,
5782
- data: {
5783
- key: current.key,
5784
- name: current.name,
5785
- tickets: removed.length
5903
+ await recordAudience(ctx, requireRow((await publishMany(ctx, [
5904
+ ...removed.map((ticket) => ({
5905
+ type: "ticket.deleted",
5906
+ ticketId: ticket.id,
5907
+ data: {
5908
+ key: ticket.key,
5909
+ version: ticket.version
5910
+ }
5911
+ })),
5912
+ ...await severedRelationEvents(ctx),
5913
+ {
5914
+ type: "project.deleted",
5915
+ ticketId: null,
5916
+ data: {
5917
+ key: current.key,
5918
+ name: current.name,
5919
+ tickets: removed.length
5920
+ }
5786
5921
  }
5787
- }], false)).at(-1)), readers.map((person) => person.userId));
5922
+ ], false)).at(-1)), readers.map((person) => person.userId));
5788
5923
  await ctx.db.delete(tickets).where(eq(tickets.projectId, current.id));
5789
5924
  await ctx.db.delete(stagedUploads).where(eq(stagedUploads.projectId, current.id));
5790
5925
  await ctx.db.delete(projectLabels).where(eq(projectLabels.projectId, current.id));
@@ -6021,7 +6156,8 @@ const timeEntryHandlers = {
6021
6156
  //#region src/public-operations.ts
6022
6157
  const listTickets = async (ctx, input) => parseOutput("list_tickets", json(await ticketHandlers.list_tickets(ctx, operations.list_tickets.input.parse({
6023
6158
  ...input,
6024
- projectId: project(ctx).id
6159
+ projectId: project(ctx).id,
6160
+ view: "full"
6025
6161
  }))));
6026
6162
  async function published(ctx, ids) {
6027
6163
  if (!ids.length) return /* @__PURE__ */ new Set();
@@ -6824,7 +6960,13 @@ function json(value) {
6824
6960
  return JSON.parse(JSON.stringify(value, (_, item) => typeof item === "bigint" ? item.toString() : item));
6825
6961
  }
6826
6962
  const complete = (status) => closedStates.some((closed) => closed === status);
6827
- const hasScope = (actor, scope) => actor.scopes.includes("admin") || actor.scopes.includes(scope) || scope === "read" && actor.scopes.length > 0;
6963
+ const intakeHoldReason = "Untrusted intake: human triage required";
6964
+ const intakeQuarantined = (ticket) => ticket.holdReason === intakeHoldReason;
6965
+ const intakeOnly = (actor) => actor.scopes.length > 0 && actor.scopes.every((scope) => scope === "intake");
6966
+ 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");
6967
+ function requireScope(actor, scope, operation) {
6968
+ if (!hasScope(actor, scope, operation)) fail(403, "insufficient_scope", `This credential requires ${scope} permission`);
6969
+ }
6828
6970
  const capabilities = (actor, granted) => granted.filter((permission) => hasScope(actor, permissionScope(permission)));
6829
6971
  const can = (ctx, permission) => capabilities(ctx.actor, ctx.permissions).includes(permission);
6830
6972
  function requirePermission(granted, permission, where) {
@@ -6875,8 +7017,8 @@ function ticketMatch(identifier) {
6875
7017
  const key = identifier.toUpperCase(), [, prefix, number] = issuedKey.exec(key) ?? [];
6876
7018
  return or(sql`${tickets.id}::text = ${identifier}`, eq(tickets.key, key), sql`${tickets.id} IN (SELECT ta.ticket_id FROM ${ticketAliases} ta WHERE ta.key = ${key})`, prefix && number ? and(eq(tickets.number, Number(number)), sql`${tickets.projectId} IN (SELECT pp.project_id FROM ${projectPrefixes} pp WHERE pp.prefix = ${prefix})`) : void 0);
6877
7019
  }
6878
- async function context(db, actor, scope, projectId, workspaceId, permission = scope === "read" ? null : scope === "admin" ? "project.manage" : "ticket.write") {
6879
- if (!hasScope(actor, scope)) fail(403, "insufficient_scope", `This credential requires ${scope} permission`);
7020
+ async function context(db, actor, scope, projectId, workspaceId, permission = scope === "read" ? null : scope === "admin" ? "project.manage" : "ticket.write", operation) {
7021
+ requireScope(actor, scope, operation);
6880
7022
  const ctx = baseContext(db, actor);
6881
7023
  if (workspaceId) {
6882
7024
  const access = await requireWorkspace(ctx, workspaceId, permission);
@@ -7052,11 +7194,26 @@ async function publish(ctx, type, ticketId, data) {
7052
7194
  data
7053
7195
  }]));
7054
7196
  }
7055
- const relationEvents = (type, relation) => [relation.sourceId, relation.targetId].map((ticketId) => ({
7197
+ const relationEvents = (type, relation, sides) => [relation.sourceId, relation.targetId].map((ticketId) => ({
7056
7198
  type,
7057
7199
  ticketId,
7058
- data: { relation }
7200
+ data: { relation },
7201
+ projectId: requireRow(sides.find((side) => side.id === ticketId)).projectId
7059
7202
  }));
7203
+ const deletedSide = alias(tickets, "deleted_side");
7204
+ const survivingSide = alias(tickets, "surviving_side");
7205
+ async function severedRelationEvents(ctx, ticketIds) {
7206
+ return (await ctx.db.select({
7207
+ relation: relations$1,
7208
+ ticketId: survivingSide.id,
7209
+ projectId: survivingSide.projectId
7210
+ }).from(relations$1).innerJoin(deletedSide, or(eq(deletedSide.id, relations$1.sourceId), eq(deletedSide.id, relations$1.targetId))).innerJoin(survivingSide, or(eq(survivingSide.id, relations$1.sourceId), eq(survivingSide.id, relations$1.targetId))).where(and(ticketIds ? inArray(deletedSide.id, [...ticketIds]) : eq(deletedSide.projectId, project(ctx).id), ne(survivingSide.projectId, project(ctx).id)))).map(({ relation, ticketId, projectId }) => ({
7211
+ type: "relation.removed",
7212
+ ticketId,
7213
+ projectId,
7214
+ data: { relation }
7215
+ }));
7216
+ }
7060
7217
  function ownsLiveClaim(ctx, claim) {
7061
7218
  return claim.ownerId === ctx.actor.ownerId && claim.expiresAt > /* @__PURE__ */ new Date() && hasScope(ctx.actor, "claim") && ctx.permissions.includes("ticket.write");
7062
7219
  }
@@ -7148,6 +7305,7 @@ function createDomain(database, services = {}) {
7148
7305
  const input = spec.input.parse(value);
7149
7306
  const projectId = "projectId" in input ? input.projectId : void 0;
7150
7307
  const workspaceId = "workspaceId" in input && !workspaceIsFilter.has(name) ? input.workspaceId : void 0;
7308
+ if (intakeOnly(actor)) requireScope(actor, spec.scope, name);
7151
7309
  if (actor.machine && sessionOnly.has(name)) fail(403, "session_required", "Use a signed-in account for this action");
7152
7310
  if (actor.projectIds && spec.method !== "GET" && !projectId) fail(403, "insufficient_scope", "Project-bound credentials can only change their configured projects");
7153
7311
  const outgoing = [];
@@ -7161,19 +7319,20 @@ function createDomain(database, services = {}) {
7161
7319
  return parseOutput(name, json(await handler(ctx, input)));
7162
7320
  };
7163
7321
  const permission = operationPermission(name);
7164
- if (spec.method === "GET" || remoteReads.has(name)) return invoke(await context(database.db, actor, spec.scope, projectId, workspaceId, permission));
7322
+ if (spec.method === "GET" || remoteReads.has(name)) return invoke(await context(database.db, actor, spec.scope, projectId, workspaceId, permission, name));
7165
7323
  const requestId = "requestId" in input ? input.requestId : void 0;
7166
7324
  const reader = sourceReads[name];
7167
- if (reader && !(requestId && (await database.db.select({ id: receipts.requestId }).from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1)).length)) read = await reader.read(withServices(await context(database.db, actor, spec.scope, projectId, workspaceId, permission)), input);
7325
+ if (reader && !(requestId && (await database.db.select({ id: receipts.requestId }).from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1)).length)) read = await reader.read(withServices(await context(database.db, actor, spec.scope, projectId, workspaceId, permission, name)), input);
7168
7326
  const committed = await locked(async (tx) => {
7169
7327
  const hash = createHash("sha256").update(JSON.stringify({
7170
7328
  name,
7171
- input: reader ? reader.receipt(input) : input
7329
+ input: reader ? reader.receipt(input) : input,
7330
+ ...intakeOnly(actor) && { intakeCredential: actor.ownerId }
7172
7331
  })).digest("hex");
7173
7332
  const receipt = requestId ? (await tx.select().from(receipts).where(and(eq(receipts.actorId, actor.id), eq(receipts.requestId, requestId))).limit(1))[0] : void 0;
7174
7333
  if (receipt && (receipt.hash !== hash || receipt.operation !== name)) fail(409, "idempotency_conflict", "This requestId was already used for another write");
7175
7334
  if (receipt && (name === "delete_project" || name === "delete_workspace")) return parseOutput(name, receipt.result);
7176
- const ctx = await context(tx, actor, spec.scope, projectId, workspaceId, permission);
7335
+ const ctx = await context(tx, actor, spec.scope, projectId, workspaceId, permission, name);
7177
7336
  if (receipt) {
7178
7337
  if ((name === "create_time_entry" || name === "update_time_entry" || name === "delete_time_entry") && "ticketId" in input) await getTicket(ctx, input.ticketId);
7179
7338
  const prior = parseOutput(name, receipt.result);
@@ -7670,6 +7829,8 @@ function createAuth(database, config) {
7670
7829
  schema: auth_schema_exports,
7671
7830
  transaction: true
7672
7831
  });
7832
+ const verification = new AsyncLocalStorage();
7833
+ const consumed = () => verification.getStore()?.row ?? null;
7673
7834
  const instance = createInstance(database, config);
7674
7835
  const credentials = socialCredentials(config);
7675
7836
  const mail = config.SMTP_URL && config.SMTP_FROM ? createMailSender(config.SMTP_URL, config.SMTP_FROM) : void 0;
@@ -7704,23 +7865,35 @@ function createAuth(database, config) {
7704
7865
  disableIdTokenSignIn: true
7705
7866
  };
7706
7867
  };
7707
- return betterAuth({
7868
+ const auth = betterAuth({
7708
7869
  appName: "Kanvil",
7709
7870
  baseURL: config.KANVIL_URL,
7710
7871
  secret: config.BETTER_AUTH_SECRET,
7711
7872
  database: (options) => {
7712
7873
  const base = adapter(options);
7713
- const update = (input) => {
7874
+ const update = async (input) => {
7714
7875
  const fields = Object.keys(input.update);
7715
- if (input.model === "apikey" && fields.length > 0 && fields.every((field) => field === "lastRequest" || field === "updatedAt")) return base.findOne({
7876
+ if (input.model === "apikey" && fields.length > 0 && fields.every((field) => field === "lastRequest" || field === "updatedAt")) return await base.findOne({
7716
7877
  model: input.model,
7717
7878
  where: input.where
7718
- });
7879
+ }) ?? consumed();
7719
7880
  return base.update(input);
7720
7881
  };
7882
+ const incrementOne = async (input) => {
7883
+ const row = await base.incrementOne(input);
7884
+ const store = input.model === "apikey" ? verification.getStore() : void 0;
7885
+ if (!store) return row;
7886
+ if (row) store.row = row;
7887
+ else if (store.row && !await base.findOne({
7888
+ model: input.model,
7889
+ where: input.where.filter(({ field }) => field === "id")
7890
+ })) return store.row;
7891
+ return row;
7892
+ };
7721
7893
  return {
7722
7894
  ...base,
7723
- update
7895
+ update,
7896
+ incrementOne
7724
7897
  };
7725
7898
  },
7726
7899
  trustedOrigins: config.trustedOrigins,
@@ -7871,6 +8044,14 @@ function createAuth(database, config) {
7871
8044
  }] : [] })
7872
8045
  ]
7873
8046
  });
8047
+ const verifyApiKey = Object.assign((input) => verification.run({}, () => auth.api.verifyApiKey(input)), auth.api.verifyApiKey);
8048
+ return {
8049
+ ...auth,
8050
+ api: {
8051
+ ...auth.api,
8052
+ verifyApiKey
8053
+ }
8054
+ };
7874
8055
  }
7875
8056
  //#endregion
7876
- export { storageBackends as $, readCalendarFeed as A, attachments as B, deliverBatch as C, createWriteLimiter as D, createAccess as E, imageResponse as F, instanceSettings as G, deliveryCheckpoints as H, stagedUploadCutoff as I, notifications as J, members as K, createObjectStore as L, createTicketIndex as M, createBlobStorage as N, requireInstanceAdmin as O, decodeImage as P, stagedUploads as Q, createPollingJob as R, defaultSenders as S, createHttpSender as T, eventClock as U, blobObjects as V, events as W, projects as X, profilePhotos as Y, pushDevices as Z, reachable as _, deliverySecrets as a, processImports as b, ownerBlocker as c, createDomain as d, subscriptions as et, errorEnvelope as f, one as g, notify as h, createInstance as i, matchesOIDCBinding as it, createTicketSearch as j, requireSession as k, appendEvents as l, json as m, emailCodesAvailable as n, workspaceMembers as nt, otherActiveAdministrator as o, fail as p, notificationPreferences as q, signInProviders as r, workspaces as rt, ownedWorkspaces as s, createAuth as t, tickets as tt, assertVersion as u, recipientColumns as v, DeliveryError as w, receiveRepositoryWebhook as x, requireRow as y, attachmentContent as z };
8057
+ 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 };