Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
-- #10120 (follow-up to #8972 ticket merge, #8313 ticket import): record the
-- ticket a merge moved a ticket_external_refs row off.
--
-- ticket_external_refs.origin_ticket_id plain FK → tickets ON DELETE SET NULL
--
-- Set only by the moved-ref UPDATE in services/ticketMerge/repoint.ts, to the
-- tombstone the ref was written on (COALESCE: a ref moved A→B→C keeps A, as
-- ticket_comments.merged_from_ticket_id does). NULL for a ref written
-- directly; no backfill: refs moved before this column keep today's answer.
--
-- Same shape as ticket_comments.merged_from_ticket_id
-- (2027-01-04-110000-ticket-merge.sql): not composite with org_id (the row's
-- own org_id RLS already fences the referenced ticket, and a tombstone shares
-- its survivor's org), so no DEFERRABLE requirement; ON DELETE SET NULL with a
-- nullable column is safe for the org-erasure cascade on its own. No index,
-- as for merged_from_ticket_id: tickets are hard-deleted only by the org
-- cascade, which deletes these refs in the same erasure.
--
-- Export policy: included (one id). Writes no rows.

ALTER TABLE public.ticket_external_refs ADD COLUMN IF NOT EXISTS origin_ticket_id uuid;

ALTER TABLE public.ticket_external_refs
DROP CONSTRAINT IF EXISTS ticket_external_refs_origin_ticket_id_tickets_id_fk,
ADD CONSTRAINT ticket_external_refs_origin_ticket_id_tickets_id_fk
FOREIGN KEY (origin_ticket_id) REFERENCES public.tickets (id) ON DELETE SET NULL;
Original file line number Diff line number Diff line change
Expand Up @@ -728,7 +728,7 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => {
expect(await getTestDb().select().from(tickets).where(inArray(tickets.orgId, [other.orgA.id, fx.orgA.id]))).toEqual([]);
});

runDb('a ticket merged in Breeze between two passes is skipped as merged_in_breeze; a ref the merge moved is refused; nothing is written or charged', async () => {
runDb('a ticket merged in Breeze between two passes is skipped as merged_in_breeze, also when the merge moved its ref; a ref moved with no origin is refused; nothing is written or charged', async () => {
const fx = await seed();
const first = await openWindow(fx);
const open = { assigneeUserId: undefined, finalStatus: 'open' as const, resolvedAt: undefined, closedAt: undefined, resolutionNote: undefined };
Expand Down Expand Up @@ -773,6 +773,9 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => {
expect(await refOf(a.sourceId)).toBe(ra.ticketId);
expect(await refOf(c.sourceId)).toBe(live.id);
expect(await refOf(e.sourceId)).toBe(rx.ticketId);
// E's ref stands for one moved before origin_ticket_id existed (#10120: no backfill).
await getTestDb().update(ticketExternalRefs).set({ originTicketId: null })
.where(and(eq(ticketExternalRefs.partnerServicePrincipalId, fx.principal.id), eq(ticketExternalRefs.externalId, e.sourceId)));

const touched = [ra.ticketId, rb.ticketId, rc.ticketId, rd.ticketId, re.ticketId, live.id, rx.ticketId];
const snapshot = async () => ({
Expand All @@ -791,10 +794,16 @@ describe('ticket import mode (real Postgres, as breeze_app)', () => {
skip: { reason: 'merged_in_breeze', mergedIntoTicketId: rb.ticketId },
commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NO_FILES, ...NOTHING_CHANGED,
});
// A moved ref resolves to a ticket this principal did not import: refused,
// never resumed onto it (the known limit: on every pass).
await expect(importTicket(second.id, { ...c, comments: [...c.comments, article(6)] }, { partnerId: fx.partner.id }))
.rejects.toMatchObject({ code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'live_ticket', ticketId: live.id } });
// A moved ref names its origin, this principal's tombstone C (#10120):
// the same answer as a ref still on the tombstone.
expect(await importTicket(second.id, { ...c, comments: [...c.comments, article(6)] }, { partnerId: fx.partner.id })).toEqual({
ticketId: rc.ticketId,
outcome: 'skipped',
skip: { reason: 'merged_in_breeze', mergedIntoTicketId: live.id },
commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0, statusSkipped: false, ...NO_FILES, ...NOTHING_CHANGED,
});
// A moved ref with no origin resolves to a ticket this principal did not
// import: refused, never resumed onto it (on every pass).
await expect(importTicket(second.id, { ...e, comments: [...e.comments, article(10)] }, { partnerId: fx.partner.id }))
.rejects.toMatchObject({ code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'other_principal_ticket', ticketId: rx.ticketId } });
expect(await snapshot()).toEqual(before);
Expand Down
28 changes: 26 additions & 2 deletions apps/api/src/__tests__/integration/ticketMerge.integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { previewMerge } from '../../services/ticketMerge/preview';
import { db } from '../../db';
import { MergeRefusedError, TicketMergedError } from '../../services/ticketMerge/errors';
import { addTicketComment, type TicketActor } from '../../services/ticketService';
import { upsertTicketExternalRef } from '../../services/ticketExternalRefs';
import { createDevice, createOrganization, createPartner, createSite, createUser } from './db-utils';
import { getTestDb } from './setup';
import { businessCalendarIntervals, businessCalendarRevisions, businessCalendars } from '../../db/schema';
Expand Down Expand Up @@ -235,9 +236,32 @@ describe('mergeTickets (#8972 W1, spec §3.3)', () => {
INSERT INTO ticket_external_refs (ticket_id, org_id, partner_id, partner_service_principal_id, external_id)
VALUES (${s.src}::uuid, ${s.org.id}::uuid, ${s.partner.id}::uuid, ${p2}::uuid, 'SRC-2') RETURNING id`);
const res = await merge(s);
expect(await one(sql`SELECT ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)).toEqual({ ticket_id: s.dst });
expect(await one(sql`SELECT ticket_id FROM ticket_external_refs WHERE id = ${collide.id}::uuid`)).toEqual({ ticket_id: s.src });
// The moved ref records the ticket it came off (#10120); a ref that stays
// and one written directly on the survivor have no origin.
expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`))
.toEqual({ ticket_id: s.dst, origin_ticket_id: s.src });
expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${collide.id}::uuid`))
.toEqual({ ticket_id: s.src, origin_ticket_id: null });
expect(await one(sql`SELECT origin_ticket_id FROM ticket_external_refs WHERE external_id = 'DST-1' AND partner_service_principal_id = ${p1}::uuid`))
.toEqual({ origin_ticket_id: null });
expect(res.retainedExternalRefIds).toEqual([collide.id]);

// Merged again (B→C): the ref keeps its earliest origin, as a comment does.
const c = await insertTicket(s.org.id, s.partner.id, { createdAt: new Date(Date.now() - 7_200_000) });
await merge(s, s.dst, c);
expect(await one(sql`SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`))
.toEqual({ ticket_id: c, origin_ticket_id: s.src });

// The Partner API's upsert keeps the origin for the same external id, and
// drops it for a new one, which is written directly.
const upsert = (externalId: string) => withDbAccessContext(s.ctx, () => upsertTicketExternalRef({
principalId: p2, partnerId: s.partner.id, ticketId: c, externalId,
}));
await upsert('SRC-2');
expect(await one(sql`SELECT origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`)).toEqual({ origin_ticket_id: s.src });
await upsert('NEW-2');
expect(await one(sql`SELECT external_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${moves.id}::uuid`))
.toEqual({ external_id: 'NEW-2', origin_ticket_id: null });
}, T);

runDb('links: between-pair deleted, related re-canonicalised, collisions dropped and listed, duplicate_of follows the survivor', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
* tickets.merged_at CHECK (merged_into IS NULL) = (merged_at IS NULL)
* tickets.merged_by_user_id → users ON DELETE SET NULL
* ticket_comments.merged_from_ticket_id → tickets ON DELETE SET NULL
* ticket_external_refs.origin_ticket_id → tickets ON DELETE SET NULL (#10120)
*/
import './setup';
import { sql } from 'drizzle-orm';
Expand Down Expand Up @@ -140,6 +141,27 @@ describe('ticket merge schema (#8972 W1, spec §3.2)', () => {
expect(after).toEqual([{ merged_from_ticket_id: null }]);
});

runDb('origin_ticket_id on external refs nulls when its ticket is hard-deleted (#10120)', async () => {
const { partner, org } = await seedOrg();
const user = await createUser({ partnerId: partner.id });
const src = await insertTicket(org.id, partner.id);
const dst = await insertTicket(org.id, partner.id);
const [principal] = (await getTestDb().execute(sql`
INSERT INTO partner_service_principals (partner_id, name, scopes, created_by, updated_by)
VALUES (${partner.id}::uuid, 'origin-fk', ARRAY['tickets:read'], ${user.id}::uuid, ${user.id}::uuid) RETURNING id
`)) as unknown as Array<{ id: string }>;
const rows = (await getTestDb().execute(sql`
INSERT INTO ticket_external_refs (ticket_id, org_id, partner_id, partner_service_principal_id, external_id, origin_ticket_id)
VALUES (${dst}::uuid, ${org.id}::uuid, ${partner.id}::uuid, ${principal!.id}::uuid, 'ORIGIN-1', ${src}::uuid)
RETURNING id
`)) as unknown as Array<{ id: string }>;
await getTestDb().execute(sql`DELETE FROM tickets WHERE id = ${src}::uuid`);
const after = (await getTestDb().execute(sql`
SELECT ticket_id, origin_ticket_id FROM ticket_external_refs WHERE id = ${rows[0]!.id}::uuid
`)) as unknown as Array<{ ticket_id: string; origin_ticket_id: string | null }>;
expect(after).toEqual([{ ticket_id: dst, origin_ticket_id: null }]);
});

runDb('partial index exists and is valid', async () => {
const rows = (await getTestDb().execute(sql`
SELECT i.indisvalid, pg_get_expr(i.indpred, i.indrelid) AS pred
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/db/schema/ticketExternalRefs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ export const ticketExternalRefs = pgTable('ticket_external_refs', {
// index idx_ticket_external_refs_org_external_number (org_id,
// lower(external_number)); externalId stays the stable resume key.
externalNumber: varchar('external_number', { length: 255 }),
// The ticket this ref was written on, when a merge moved it onto its
// survivor (#10120, 2027-01-08-120000): set only by ticketMerge/repoint.ts,
// kept across a second merge (A→B→C keeps A), NULL for a ref written
// directly. A plain FK like ticket_comments.merged_from_ticket_id.
originTicketId: uuid('origin_ticket_id').references(() => tickets.id, { onDelete: 'set null' }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (t) => [
Expand Down
2 changes: 1 addition & 1 deletion apps/api/src/services/tenantExportPolicyRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -988,7 +988,7 @@ export const CORE_TENANT_EXPORT_POLICY: TenantExportPolicyRegistry = {
excludedSensitive: [],
excludedOpen: [],
}),
"ticket_external_refs": tablePolicy("org_id", {"included":["external_number","id","ticket_id","org_id","partner_id","partner_service_principal_id","external_id","external_url","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}),
"ticket_external_refs": tablePolicy("org_id", {"included":["external_number","id","ticket_id","org_id","partner_id","partner_service_principal_id","external_id","external_url","created_at","updated_at","origin_ticket_id"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}),
"ticket_form_org_links": tablePolicy("org_id", {"included":["id","form_id","org_id","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}),
"ticket_forms": tablePolicy("org_id", {"included":["id","partner_id","org_id","name","description","category_id","title_template","description_intro","default_priority","default_tags","show_in_portal","is_active","sort_order","version","created_by","created_at","updated_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":["fields"]}),
"ticket_links": tablePolicy("org_id", {"included":["id","org_id","source_ticket_id","target_ticket_id","link_type","created_by","created_at"],"reviewedIncluded":[],"excludedSensitive":[],"excludedOpen":[]}),
Expand Down
6 changes: 5 additions & 1 deletion apps/api/src/services/ticketExternalRefs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, eq, inArray } from 'drizzle-orm';
import { and, eq, inArray, sql } from 'drizzle-orm';
import { db } from '../db';
import { ticketExternalRefs, tickets } from '../db/schema';
import { pgErrorConstraint } from '../utils/pgErrors';
Expand Down Expand Up @@ -102,6 +102,10 @@ export async function upsertTicketExternalRef(input: {
externalId: input.externalId,
externalUrl: input.externalUrl ?? null,
...(input.externalNumber !== undefined ? { externalNumber: input.externalNumber } : {}),
// A ref a merge moved (#10120) keeps its origin only while it
// names the same external id: a new one is written here directly.
originTicketId: sql`CASE WHEN ticket_external_refs.external_id = excluded.external_id
THEN ticket_external_refs.origin_ticket_id END`,
updatedAt: new Date(),
},
})
Expand Down
35 changes: 34 additions & 1 deletion apps/api/src/services/ticketImport/importWriter.flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,8 +397,41 @@ describe('importTicket — a ticket merged in Breeze (#8972) is skipped, final,
expect(h.writes).toEqual([]);
});

it('answers a ref the merge moved onto the survivor from its origin, this principal\'s tombstone (#10120)', async () => {
const ORIGIN = '77777777-7777-4777-8777-777777777777';
answer('ref', [{ ticketId: SURVIVOR, externalNumber: '#41023', externalUrl: null, originTicketId: ORIGIN }]);
// The survivor is a live ticket; the origin is this principal's import, merged into it.
answer('storedTicket',
[storedRow({ id: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })],
[storedRow({ id: ORIGIN, mergedIntoTicketId: SURVIVOR, status: 'closed' })]);

const result = await run(closedWithNotes);

expect(result).toMatchObject({
ticketId: ORIGIN, outcome: 'skipped', skip: { reason: 'merged_in_breeze', mergedIntoTicketId: SURVIVOR },
commentsWritten: 0, commentsSkipped: 0, fieldsUpdated: 0,
});
expect(h.log).toEqual(['ref', 'storedTicket', 'storedTicket']);
expect(h.locks).toBe(0);
expect(h.charges).toEqual([]);
expect(h.writes).toEqual([]);
});

it.each([
["another principal's tombstone", [storedRow({ mergedIntoTicketId: SURVIVOR, windowPrincipalId: '45454545-4545-4545-8545-454545454545' })]],
['a live tombstone', [storedRow({ mergedIntoTicketId: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })]],
['a ticket the window cannot see', []],
])('refuses a moved ref whose origin is %s', async (_label, originRows) => {
answer('ref', [{ ticketId: SURVIVOR, externalNumber: '#41023', externalUrl: null, originTicketId: TICKET }]);
answer('storedTicket', [storedRow({ id: SURVIVOR, migrationWindowId: null, windowPrincipalId: null })], originRows);
await expect(run(closedWithNotes)).rejects.toMatchObject({
code: 'MIGRATION_SOURCE_ID_IN_USE', status: 409, details: { reason: 'live_ticket', ticketId: SURVIVOR },
});
expect(h.writes).toEqual([]);
});

it.each([
// The merge moved this import's ref onto a live survivor (the known limit: refused on every pass).
// The merge moved this import's ref onto a live survivor before origin_ticket_id existed (refused on every pass).
['a live ticket', { migrationWindowId: null, windowPrincipalId: null }, 'live_ticket'],
// Checked before the tombstone: a never-imported live ticket that was later merged stays a conflict.
['a live tombstone', { migrationWindowId: null, windowPrincipalId: null, mergedIntoTicketId: SURVIVOR }, 'live_ticket'],
Expand Down
31 changes: 22 additions & 9 deletions apps/api/src/services/ticketImport/importWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,9 +79,11 @@ import { lockOpenWindowForWrite, readWindowDescriptor, windowWriteContext, type
* Merged in Breeze (#8972): a ticket merged into another is a read-only
* tombstone. Resuming one this principal imported writes nothing, charges
* nothing and returns `outcome: 'skipped'` with `skip.reason:
* 'merged_in_breeze'` and the survivor (see `lockImportedTicket`). A ref that
* resolves to a ticket this principal did not import (a live ticket, merged
* or not, or another principal's import) is refused, never resumed.
* 'merged_in_breeze'` and the survivor (see `lockImportedTicket`), also when
* the merge moved the ref onto the survivor (`origin_ticket_id`, #10120).
* Any other ref that resolves to a ticket this principal did not import (a
* live ticket, merged or not, or another principal's import) is refused,
* never resumed.
*
* Attachments (#8313 W1; spec 2026-10-19 §4): the files of each comment chunk
* are reserved (pending rows, one unit each, at most 100 per transaction),
Expand Down Expand Up @@ -153,11 +155,11 @@ export type ImportTicketResult = {
* merge that committed mid-ticket), the transactions before it stay
* committed, that transaction's charge is spent and it writes nothing.
*
* Known limit: when the survivor had no ref from this principal, the merge
* moves the tombstone's ref onto the survivor (ticketMerge/repoint.ts). The
* source id then resolves to a ticket this principal did not import, and the
* resume is refused (`live_ticket` or `other_principal_ticket`) on every pass:
* nothing on the moved ref says where it came from.
* When the survivor had no ref from this principal, the merge moves the
* tombstone's ref onto the survivor (ticketMerge/repoint.ts) and records the
* tombstone in `origin_ticket_id` (#10120); the answer is the same. A ref
* moved before that column existed has no origin, and the resume is refused
* (`live_ticket` or `other_principal_ticket`) on every pass.
*/
export type ImportSkip = { reason: 'merged_in_breeze'; mergedIntoTicketId: string };

Expand Down Expand Up @@ -253,6 +255,8 @@ type StoredTicket = HeaderRow & {
};

type StoredRef = { ticketId: string; externalNumber: string | null; externalUrl: string | null };
/** The ref the plan resolves, with the ticket a merge moved it off (#10120). */
type ResolvedRef = StoredRef & { originTicketId: string | null };

type HeaderChanges = {
fields: UpdateTicketFieldsInput;
Expand Down Expand Up @@ -313,12 +317,13 @@ function toCreateInput(t: ImportedTicket): CreateTicketInput {
} as CreateTicketInput;
}

async function readRef(principalId: string, sourceId: string): Promise<StoredRef | null> {
async function readRef(principalId: string, sourceId: string): Promise<ResolvedRef | null> {
const [row] = await db
.select({
ticketId: ticketExternalRefs.ticketId,
externalNumber: ticketExternalRefs.externalNumber,
externalUrl: ticketExternalRefs.externalUrl,
originTicketId: ticketExternalRefs.originTicketId,
})
.from(ticketExternalRefs)
.where(and(eq(ticketExternalRefs.partnerServicePrincipalId, principalId), eq(ticketExternalRefs.externalId, sourceId)))
Expand Down Expand Up @@ -801,6 +806,14 @@ export async function importTicket(
// A ref whose ticket the window's RLS hides: the same reason code the
// locked re-check gives (lockImportedTicket).
if (!stored) throw inUse(t.sourceId, { reason: 'org_outside_window', ticketId: ref.ticketId });
// Merged in Breeze, and the merge moved the ref onto the survivor
// (#10120): the ticket it was written on is this principal's tombstone.
if (ref.originTicketId) {
const origin = await readStoredTicket(ref.originTicketId);
if (origin?.mergedIntoTicketId && origin.migrationWindowId && origin.windowPrincipalId === principalId) {
return { kind: 'merged' as const, ticketId: origin.id, mergedIntoTicketId: origin.mergedIntoTicketId };
}
}
// A ticket this principal did not import is never resumed, merged or not
// (checked first, so a never-imported live ticket that was merged stays a
// conflict, not a final skip): a live ticket, or another principal's
Expand Down
Loading
Loading